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

View File

@@ -0,0 +1,138 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// PR E.7 — pins that the GalaxyDriver populates the extended AlarmEventArgs
/// fields (OperatorComment, OriginalRaiseTimestampUtc, AlarmCategory) when the
/// gateway emits a transition with the rich payload, and leaves them null on
/// events that don't carry them.
/// </summary>
public sealed class GalaxyDriverAlarmEventArgsExtensionTests
{
[Fact]
public async Task Acknowledge_transition_with_full_payload_populates_extended_fields()
{
var subscriber = new ManualSubscriber();
using var driver = NewDriver(subscriber);
await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
var ack = raise.AddSeconds(45);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Acknowledge,
Severity = 750,
OriginalRaiseTimestamp = Timestamp.FromDateTime(raise),
TransitionTimestamp = Timestamp.FromDateTime(ack),
OperatorUser = "alice",
OperatorComment = "investigating",
Category = "Process",
Description = "Tank 01 high-high level",
},
});
for (var i = 0; i < 20 && observed.Count == 0; i++)
{
await Task.Delay(50);
}
observed.ShouldHaveSingleItem();
observed[0].OperatorComment.ShouldBe("investigating");
observed[0].OriginalRaiseTimestampUtc.ShouldBe(raise);
observed[0].AlarmCategory.ShouldBe("Process");
}
[Fact]
public async Task Raise_transition_without_optional_fields_leaves_them_null()
{
var subscriber = new ManualSubscriber();
using var driver = NewDriver(subscriber);
await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 750,
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
},
});
for (var i = 0; i < 20 && observed.Count == 0; i++)
{
await Task.Delay(50);
}
observed.ShouldHaveSingleItem();
observed[0].OperatorComment.ShouldBeNull();
observed[0].OriginalRaiseTimestampUtc.ShouldBeNull();
observed[0].AlarmCategory.ShouldBeNull();
}
private static GalaxyDriver NewDriver(ManualSubscriber subscriber)
{
var options = new GalaxyDriverOptions(
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
new GalaxyMxAccessOptions("AlarmExtensionTest"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
return new GalaxyDriver(
driverInstanceId: "drv-1",
options: options,
hierarchySource: null,
dataReader: null,
dataWriter: null,
subscriber: subscriber,
alarmAcknowledger: null);
}
private sealed class ManualSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
var results = new List<SubscribeResult>();
var nextHandle = 100;
foreach (var r in fullReferences)
{
results.Add(new SubscribeResult { TagAddress = r, ItemHandle = nextHandle++, WasSuccessful = true });
}
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
}
}

View File

@@ -0,0 +1,244 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// PR B.2 — pins GalaxyDriver's IAlarmSource implementation. The driver bridges
/// EventPump.OnAlarmTransition (PR B.1) onto IAlarmSource.OnAlarmEvent and
/// forwards Acknowledge through IGalaxyAlarmAcknowledger (production:
/// GatewayGalaxyAlarmAcknowledger calling the gateway's AcknowledgeAlarm RPC
/// from PR E.2).
/// </summary>
public sealed class GalaxyDriverAlarmSourceTests
{
[Fact]
public async Task SubscribeAlarmsAsync_returns_handle_and_event_fires_after_pump_alarm()
{
var subscriber = new ManualSubscriber();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
// Subscribe so OnAlarmEvent has a registered handle to fire under.
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
handle.ShouldNotBeNull();
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
// SubscribeAsync to start the EventPump (alarm wiring is lazy on first sub).
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 750,
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
Description = "Tank 01 high-high level",
},
});
// Drain pump events.
for (var i = 0; i < 20 && observed.Count == 0; i++)
{
await Task.Delay(50);
}
observed.ShouldHaveSingleItem();
observed[0].ConditionId.ShouldBe("Tank01.Level.HiHi");
observed[0].SourceNodeId.ShouldBe("Tank01");
observed[0].AlarmType.ShouldBe("AnalogLimitAlarm.HiHi");
observed[0].Severity.ShouldBe(AlarmSeverity.Critical);
observed[0].SubscriptionHandle.ShouldBe(handle);
}
[Fact]
public async Task OnAlarmEvent_does_not_fire_when_no_subscription_active()
{
var subscriber = new ManualSubscriber();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
// Start the pump via a data subscription so alarm events flow but no alarm
// subscription is registered → OnAlarmEvent is suppressed.
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
},
});
await Task.Delay(150);
observed.ShouldBeEmpty();
}
[Fact]
public async Task UnsubscribeAlarmsAsync_stops_event_flow()
{
var subscriber = new ManualSubscriber();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
var handle = await driver.SubscribeAlarmsAsync(["Tank01"], CancellationToken.None);
var observed = new List<AlarmEventArgs>();
driver.OnAlarmEvent += (_, args) => observed.Add(args);
await driver.SubscribeAsync(["Tank01.Level"], TimeSpan.Zero, CancellationToken.None);
await driver.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await subscriber.EmitAlarmAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 600,
TransitionTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
},
});
await Task.Delay(150);
observed.ShouldBeEmpty();
}
[Fact]
public async Task UnsubscribeAlarmsAsync_throws_for_foreign_handle()
{
var subscriber = new ManualSubscriber();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
var foreignHandle = new ForeignAlarmHandle();
await Should.ThrowAsync<ArgumentException>(() =>
driver.UnsubscribeAlarmsAsync(foreignHandle, CancellationToken.None));
}
[Fact]
public async Task AcknowledgeAsync_routes_each_request_to_the_acknowledger()
{
var subscriber = new ManualSubscriber();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
var requests = new[]
{
new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", "shift handover"),
new AlarmAcknowledgeRequest("Tank02", "Tank02.Level.HiHi", "investigating"),
};
await driver.AcknowledgeAsync(requests, CancellationToken.None);
ack.Calls.Count.ShouldBe(2);
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
ack.Calls[0].Comment.ShouldBe("shift handover");
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
}
[Fact]
public async Task AcknowledgeAsync_falls_back_to_SourceNodeId_when_ConditionId_empty()
{
var subscriber = new ManualSubscriber();
var ack = new RecordingAcknowledger();
using var driver = NewDriver(subscriber, ack);
await driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("Tank01.Level.HiHi", string.Empty, null)],
CancellationToken.None);
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
}
[Fact]
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
{
var subscriber = new ManualSubscriber();
using var driver = NewDriver(subscriber, alarmAcknowledger: null);
await Should.ThrowAsync<NotSupportedException>(() =>
driver.AcknowledgeAsync(
[new AlarmAcknowledgeRequest("Tank01", "Tank01.Level.HiHi", null)],
CancellationToken.None));
}
private static GalaxyDriver NewDriver(
ManualSubscriber subscriber, IGalaxyAlarmAcknowledger? alarmAcknowledger)
{
var options = new GalaxyDriverOptions(
new GalaxyGatewayOptions("http://localhost:5000", "literal-api-key"),
new GalaxyMxAccessOptions("AlarmSourceTest"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
return new GalaxyDriver(
driverInstanceId: "drv-1",
options: options,
hierarchySource: null,
dataReader: null,
dataWriter: null,
subscriber: subscriber,
alarmAcknowledger: alarmAcknowledger);
}
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
{
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
{
Calls.Add((alarmFullReference, comment, operatorUser));
return Task.CompletedTask;
}
}
private sealed class ForeignAlarmHandle : IAlarmSubscriptionHandle
{
public string DiagnosticId => "foreign";
}
private sealed class ManualSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
var results = new List<SubscribeResult>();
var nextHandle = 100;
foreach (var r in fullReferences)
{
results.Add(new SubscribeResult { TagAddress = r, ItemHandle = nextHandle++, WasSuccessful = true });
}
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
}
}

View File

@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Follow-up #2 — pins the three resolution forms supported by
/// <see cref="GalaxyDriver.ResolveApiKey"/>: <c>env:NAME</c>, <c>file:PATH</c>,
/// and the literal-string fallback. A future DPAPI arm slots in here without
/// touching the call site.
/// </summary>
public sealed class GalaxyDriverApiKeyResolverTests
{
[Fact]
public void Literal_string_is_returned_unchanged()
{
GalaxyDriver.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
}
[Fact]
public void Env_prefix_resolves_to_environment_variable()
{
const string name = "OTOPCUA_TEST_GALAXY_API_KEY";
Environment.SetEnvironmentVariable(name, "key-from-env");
try
{
GalaxyDriver.ResolveApiKey($"env:{name}").ShouldBe("key-from-env");
}
finally
{
Environment.SetEnvironmentVariable(name, null);
}
}
[Fact]
public void Env_prefix_unset_variable_throws_with_descriptive_message()
{
const string name = "OTOPCUA_TEST_GALAXY_API_KEY_UNSET";
Environment.SetEnvironmentVariable(name, null);
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"env:{name}"));
ex.Message.ShouldContain(name);
ex.Message.ShouldContain("unset");
}
[Fact]
public void File_prefix_resolves_to_trimmed_file_contents()
{
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " key-from-file \n");
try
{
GalaxyDriver.ResolveApiKey($"file:{path}").ShouldBe("key-from-file");
}
finally
{
File.Delete(path);
}
}
[Fact]
public void File_prefix_missing_path_throws()
{
var path = Path.Combine(Path.GetTempPath(), $"does-not-exist-{Guid.NewGuid():N}.txt");
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"file:{path}"));
ex.Message.ShouldContain(path);
ex.Message.ShouldContain("doesn't exist");
}
[Fact]
public void File_prefix_empty_file_throws()
{
var path = Path.Combine(Path.GetTempPath(), $"galaxy-key-empty-{Guid.NewGuid():N}.txt");
File.WriteAllText(path, " \n ");
try
{
var ex = Should.Throw<InvalidOperationException>(() =>
GalaxyDriver.ResolveApiKey($"file:{path}"));
ex.Message.ShouldContain("empty");
}
finally
{
File.Delete(path);
}
}
}

View File

@@ -0,0 +1,242 @@
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// <summary>
/// Smoke tests for the PR 4.0 driver skeleton. The IDriver shape, factory parsing,
/// and lifecycle methods land in PR 4.0; capability bodies (browse / read / write /
/// subscribe / health forwarder / probe watcher) are tested in PRs 4.14.7 each
/// against their own seam.
/// </summary>
public sealed class GalaxyDriverFactoryTests
{
private const string MinimalConfig = """
{
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" },
"MxAccess": { "ClientName": "OtOpcUa-A" }
}
""";
[Fact]
public void CreateInstance_ParsesMinimalConfig_AndAppliesDefaults()
{
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-instance-a", MinimalConfig);
driver.DriverInstanceId.ShouldBe("galaxy-instance-a");
driver.DriverType.ShouldBe(GalaxyDriverFactoryExtensions.DriverTypeName);
driver.Options.Gateway.Endpoint.ShouldBe("https://mxgw.test:5001");
driver.Options.Gateway.ApiKeySecretRef.ShouldBe("galaxy:apiKey");
driver.Options.Gateway.UseTls.ShouldBeTrue();
driver.Options.Gateway.ConnectTimeoutSeconds.ShouldBe(10);
driver.Options.MxAccess.ClientName.ShouldBe("OtOpcUa-A");
driver.Options.MxAccess.PublishingIntervalMs.ShouldBe(1000);
driver.Options.Repository.DiscoverPageSize.ShouldBe(5000);
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue();
}
[Fact]
public void CreateInstance_OverridesDefaults_FromFullConfig()
{
const string fullConfig = """
{
"Gateway": {
"Endpoint": "https://mxgw.prod:5001",
"ApiKeySecretRef": "secret:abc",
"UseTls": false,
"CaCertificatePath": "C:/certs/ca.crt",
"ConnectTimeoutSeconds": 5,
"DefaultCallTimeoutSeconds": 3,
"StreamTimeoutSeconds": 60
},
"MxAccess": {
"ClientName": "OtOpcUa-Prod",
"PublishingIntervalMs": 250,
"WriteUserId": 17
},
"Repository": { "DiscoverPageSize": 1000, "WatchDeployEvents": false },
"Reconnect": { "InitialBackoffMs": 100, "MaxBackoffMs": 5000, "ReplayOnSessionLost": false }
}
""";
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-prod", fullConfig);
driver.Options.Gateway.UseTls.ShouldBeFalse();
driver.Options.Gateway.CaCertificatePath.ShouldBe("C:/certs/ca.crt");
driver.Options.Gateway.ConnectTimeoutSeconds.ShouldBe(5);
driver.Options.MxAccess.PublishingIntervalMs.ShouldBe(250);
driver.Options.MxAccess.WriteUserId.ShouldBe(17);
driver.Options.Repository.DiscoverPageSize.ShouldBe(1000);
driver.Options.Repository.WatchDeployEvents.ShouldBeFalse();
driver.Options.Reconnect.InitialBackoffMs.ShouldBe(100);
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeFalse();
}
[Fact]
public void CreateInstance_MissingEndpoint_Throws()
{
const string bad = """{"Gateway":{"ApiKeySecretRef":"x"},"MxAccess":{"ClientName":"y"}}""";
Should.Throw<InvalidOperationException>(
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("Gateway.Endpoint");
}
[Fact]
public void CreateInstance_MissingApiKey_Throws()
{
const string bad = """{"Gateway":{"Endpoint":"x"},"MxAccess":{"ClientName":"y"}}""";
Should.Throw<InvalidOperationException>(
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("ApiKeySecretRef");
}
[Fact]
public void CreateInstance_MissingClientName_Throws()
{
const string bad = """{"Gateway":{"Endpoint":"x","ApiKeySecretRef":"y"}}""";
Should.Throw<InvalidOperationException>(
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("MxAccess.ClientName");
}
[Fact]
public void Register_AddsFactoryToRegistry()
{
var registry = new DriverFactoryRegistry();
GalaxyDriverFactoryExtensions.Register(registry);
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
var factory = registry.TryGet(GalaxyDriverFactoryExtensions.DriverTypeName);
factory.ShouldNotBeNull();
var driver = factory!.Invoke("galaxy-x", MinimalConfig);
driver.ShouldNotBeNull();
driver.DriverInstanceId.ShouldBe("galaxy-x");
driver.DriverType.ShouldBe(GalaxyDriverFactoryExtensions.DriverTypeName);
}
[Fact]
public async Task DriverLifecycle_InitializeShutdown_ToggleHealth()
{
// Inject a no-op subscriber seam so InitializeAsync skips BuildProductionRuntimeAsync
// (no gw connect attempt). The factory tests only care about the lifecycle health
// toggle; real-runtime wire-up is exercised in PR 4.W's BuildProductionRuntime tests.
using var driver = new GalaxyDriver(
"galaxy-x", BuildOptions(), hierarchySource: null, dataReader: null,
dataWriter: null, subscriber: new NoopSubscriber());
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
await driver.ShutdownAsync(CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
driver.GetMemoryFootprint().ShouldBe(0);
await driver.FlushOptionalCachesAsync(CancellationToken.None); // no-op shouldn't throw
}
[Fact]
public async Task ReinitializeAsync_RefreshesHealth()
{
using var driver = new GalaxyDriver(
"galaxy-x", BuildOptions(), hierarchySource: null, dataReader: null,
dataWriter: null, subscriber: new NoopSubscriber());
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
var firstStamp = driver.GetHealth().LastSuccessfulRead!.Value;
// Force a measurable clock delta so the comparison is stable on fast machines.
await Task.Delay(20);
await driver.ReinitializeAsync(MinimalConfig, CancellationToken.None);
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
driver.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(firstStamp);
}
private static GalaxyDriverOptions BuildOptions() => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
/// <summary>
/// Minimum-surface <see cref="IGalaxySubscriber"/> seam — enough to satisfy
/// <see cref="GalaxyDriver.InitializeAsync"/>'s "skip production-runtime build" branch
/// without driving any actual subscribe/event-pump traffic.
/// </summary>
private sealed class NoopSubscriber : IGalaxySubscriber
{
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
}
[Fact]
public void Dispose_IsIdempotent_AndShutdownAfterDisposeIsHarmless()
{
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig);
driver.Dispose();
Should.NotThrow(() => driver.Dispose());
}
[Fact]
public async Task InitializeAfterDispose_Throws()
{
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig);
driver.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(() =>
driver.InitializeAsync(MinimalConfig, CancellationToken.None));
}
[Fact]
public void DriverImplementsAllPhase4Capabilities()
{
// PR 4.W contract pin — every capability the in-process Galaxy driver must
// expose for parity with GalaxyProxyDriver. If any of these regress, the
// Galaxy:Backend flag flip in PR 7.1 will silently lose surface.
var driver = GalaxyDriverFactoryExtensions.CreateInstance("g", MinimalConfig);
driver.ShouldBeAssignableTo<ITagDiscovery>();
driver.ShouldBeAssignableTo<IReadable>();
driver.ShouldBeAssignableTo<IWritable>();
driver.ShouldBeAssignableTo<ISubscribable>();
driver.ShouldBeAssignableTo<IRediscoverable>();
driver.ShouldBeAssignableTo<IHostConnectivityProbe>();
}
[Fact]
public async Task GetHostStatuses_AfterInitWithSeam_ReturnsEmptySnapshot()
{
// PR 4.W wire-up assertion: when InitializeAsync skips the production-runtime
// build (seam injected), no transport-forwarder or probe-watcher pushes a
// status, so the aggregator snapshot is empty. The forwarder + watcher have
// their own unit tests in PR 4.7.
using var driver = new GalaxyDriver(
"g", BuildOptions(), hierarchySource: null, dataReader: null,
dataWriter: null, subscriber: new NoopSubscriber());
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
driver.GetHostStatuses().ShouldBeEmpty();
}
[Fact]
public void DriverType_IsGalaxyMxGateway_NotLegacyGalaxy()
{
// Must match GalaxyProxyDriver's DriverType ("Galaxy") side-by-side without
// collision so DriverInstanceBootstrapper can resolve both factories.
var driver = GalaxyDriverFactoryExtensions.CreateInstance("g", MinimalConfig);
driver.DriverType.ShouldBe("GalaxyMxGateway");
}
}

View File

@@ -0,0 +1,83 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Health;
/// <summary>
/// Tests for <see cref="HostConnectivityForwarder"/>'s push path. The forwarder is a
/// thin shim over <see cref="HostStatusAggregator"/>; the only invariants worth pinning
/// are that SetTransport routes correctly under the configured client name and that
/// repeated identical pushes don't produce duplicate change events (the aggregator's
/// dedup carries that — this test asserts the forwarder doesn't re-introduce them).
/// </summary>
public sealed class HostConnectivityForwarderTests
{
[Fact]
public void SetTransport_Running_PushesUnderClientName()
{
var agg = new HostStatusAggregator();
var captured = new List<HostStatusChangedEventArgs>();
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
var fwd = new HostConnectivityForwarder("OtOpcUa-A", agg);
fwd.SetTransport(HostState.Running);
captured.Count.ShouldBe(1);
captured[0].HostName.ShouldBe("OtOpcUa-A");
captured[0].NewState.ShouldBe(HostState.Running);
agg.Snapshot()[0].HostName.ShouldBe("OtOpcUa-A");
}
[Fact]
public void SetTransport_StateTransition_FiresChange()
{
var agg = new HostStatusAggregator();
var fwd = new HostConnectivityForwarder("OtOpcUa-A", agg);
fwd.SetTransport(HostState.Running);
var captured = new List<HostStatusChangedEventArgs>();
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
fwd.SetTransport(HostState.Stopped);
captured.Count.ShouldBe(1);
captured[0].OldState.ShouldBe(HostState.Running);
captured[0].NewState.ShouldBe(HostState.Stopped);
}
[Fact]
public void SetTransport_RepeatedSameState_DoesNotFire()
{
var agg = new HostStatusAggregator();
var fwd = new HostConnectivityForwarder("OtOpcUa-A", agg);
fwd.SetTransport(HostState.Running);
var captured = new List<HostStatusChangedEventArgs>();
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
fwd.SetTransport(HostState.Running);
fwd.SetTransport(HostState.Running);
fwd.SetTransport(HostState.Running);
captured.ShouldBeEmpty();
}
[Fact]
public void Constructor_RejectsEmptyClientName()
{
var agg = new HostStatusAggregator();
Should.Throw<ArgumentException>(() => new HostConnectivityForwarder("", agg));
Should.Throw<ArgumentException>(() => new HostConnectivityForwarder(" ", agg));
}
[Fact]
public void SetTransport_AfterDispose_Throws()
{
var agg = new HostStatusAggregator();
var fwd = new HostConnectivityForwarder("OtOpcUa-A", agg);
fwd.Dispose();
Should.Throw<ObjectDisposedException>(() => fwd.SetTransport(HostState.Running));
}
}

View File

@@ -0,0 +1,137 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Health;
/// <summary>
/// Tests for <see cref="HostStatusAggregator"/> — the merge + diff logic for the
/// transport entry plus per-platform probe entries that
/// <c>IHostConnectivityProbe.GetHostStatuses()</c> surfaces.
/// </summary>
public sealed class HostStatusAggregatorTests
{
private static HostConnectivityStatus Status(string host, HostState state) =>
new(host, state, DateTime.UtcNow);
[Fact]
public void Snapshot_Empty_WhenNothingTracked()
{
var agg = new HostStatusAggregator();
agg.Snapshot().ShouldBeEmpty();
}
[Fact]
public void Update_NewHost_FiresChange_PreviousIsUnknown()
{
var agg = new HostStatusAggregator();
var captured = new List<HostStatusChangedEventArgs>();
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
agg.Update(Status("PlatformA", HostState.Running));
captured.Count.ShouldBe(1);
captured[0].HostName.ShouldBe("PlatformA");
captured[0].OldState.ShouldBe(HostState.Unknown);
captured[0].NewState.ShouldBe(HostState.Running);
}
[Fact]
public void Update_SameState_DoesNotFire()
{
var agg = new HostStatusAggregator();
agg.Update(Status("PlatformA", HostState.Running));
var captured = new List<HostStatusChangedEventArgs>();
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
agg.Update(Status("PlatformA", HostState.Running));
captured.ShouldBeEmpty();
}
[Fact]
public void Update_StateTransition_FiresChangeWithCorrectPreviousAndNew()
{
var agg = new HostStatusAggregator();
agg.Update(Status("PlatformA", HostState.Running));
var captured = new List<HostStatusChangedEventArgs>();
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
agg.Update(Status("PlatformA", HostState.Stopped));
captured.Count.ShouldBe(1);
captured[0].OldState.ShouldBe(HostState.Running);
captured[0].NewState.ShouldBe(HostState.Stopped);
}
[Fact]
public void Snapshot_ReflectsEveryUpsertedHost()
{
var agg = new HostStatusAggregator();
agg.Update(Status("Transport", HostState.Running));
agg.Update(Status("PlatformA", HostState.Running));
agg.Update(Status("PlatformB", HostState.Stopped));
var snap = agg.Snapshot();
snap.Count.ShouldBe(3);
snap.Select(s => s.HostName).OrderBy(x => x).ShouldBe(new[] { "PlatformA", "PlatformB", "Transport" });
snap.First(s => s.HostName == "PlatformB").State.ShouldBe(HostState.Stopped);
}
[Fact]
public void Update_HostNameComparison_IsCaseInsensitive()
{
var agg = new HostStatusAggregator();
var captured = new List<HostStatusChangedEventArgs>();
agg.OnHostStatusChanged += (_, e) => captured.Add(e);
agg.Update(Status("PlatformA", HostState.Running));
agg.Update(Status("platforma", HostState.Stopped)); // same host, different case
captured.Count.ShouldBe(2);
captured[1].OldState.ShouldBe(HostState.Running);
captured[1].NewState.ShouldBe(HostState.Stopped);
agg.Snapshot().Count.ShouldBe(1);
}
[Fact]
public void Remove_TrackedHost_ReturnsTrue_AndDropsFromSnapshot()
{
var agg = new HostStatusAggregator();
agg.Update(Status("PlatformA", HostState.Running));
agg.Remove("PlatformA").ShouldBeTrue();
agg.Snapshot().ShouldBeEmpty();
}
[Fact]
public void Remove_UnknownHost_ReturnsFalse()
{
var agg = new HostStatusAggregator();
agg.Remove("Nope").ShouldBeFalse();
}
[Fact]
public void ConcurrentUpdates_DoNotCorruptDictionary()
{
var agg = new HostStatusAggregator();
const int threadCount = 8;
const int updatesPerThread = 250;
var tasks = Enumerable.Range(0, threadCount).Select(t => Task.Run(() =>
{
for (var i = 0; i < updatesPerThread; i++)
{
var hostName = $"Host{(t * updatesPerThread + i) % 32}";
var state = i % 2 == 0 ? HostState.Running : HostState.Stopped;
agg.Update(Status(hostName, state));
}
})).ToArray();
Task.WaitAll(tasks);
agg.Snapshot().Count.ShouldBeLessThanOrEqualTo(32);
}
}

View File

@@ -0,0 +1,227 @@
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Health;
/// <summary>
/// Tests for <see cref="PerPlatformProbeWatcher"/> — the per-platform probe state
/// machine. Uses a fake <see cref="IGalaxySubscriber"/> to control SubscribeBulk
/// results and assert the watcher subscribes the right addresses + decodes ScanState
/// values correctly.
/// </summary>
public sealed class PerPlatformProbeWatcherTests
{
private sealed class FakeSubscriber : IGalaxySubscriber
{
public List<List<string>> Subscribes { get; } = [];
public List<int> SubscribeIntervalsMs { get; } = [];
public List<List<int>> Unsubscribes { get; } = [];
private int _nextHandle = 1;
public Dictionary<string, int> HandleByAddress { get; } = new(StringComparer.OrdinalIgnoreCase);
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
Subscribes.Add([.. fullReferences]);
SubscribeIntervalsMs.Add(bufferedUpdateIntervalMs);
var results = new List<SubscribeResult>(fullReferences.Count);
foreach (var addr in fullReferences)
{
var handle = Interlocked.Increment(ref _nextHandle);
HandleByAddress[addr] = handle;
results.Add(new SubscribeResult
{
TagAddress = addr,
ItemHandle = handle,
WasSuccessful = true,
});
}
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
Unsubscribes.Add([.. itemHandles]);
return Task.CompletedTask;
}
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> Empty();
private static async IAsyncEnumerable<MxEvent> Empty()
{
await Task.CompletedTask;
yield break;
}
}
[Fact]
public async Task SyncPlatformsAsync_SubscribesScanStateAddressForEachPlatform()
{
var subscriber = new FakeSubscriber();
var aggregator = new HostStatusAggregator();
using var watcher = new PerPlatformProbeWatcher(subscriber, aggregator);
await watcher.SyncPlatformsAsync(["PlatformA", "PlatformB"], CancellationToken.None);
subscriber.Subscribes.Count.ShouldBe(1);
subscriber.Subscribes[0].ShouldBe(new[] { "PlatformA.ScanState", "PlatformB.ScanState" });
watcher.WatchedPlatforms.OrderBy(x => x).ShouldBe(new[] { "PlatformA", "PlatformB" });
}
[Fact]
public async Task SyncPlatformsAsync_DefaultBufferedIntervalIsZero_GwCadence()
{
// PR 6.3 — without an override, the watcher passes 0 (gw default cadence) so
// existing deployments don't see a behavior change.
var subscriber = new FakeSubscriber();
using var watcher = new PerPlatformProbeWatcher(subscriber, new HostStatusAggregator());
await watcher.SyncPlatformsAsync(["PlatformA"], CancellationToken.None);
subscriber.SubscribeIntervalsMs.ShouldHaveSingleItem().ShouldBe(0);
}
[Fact]
public async Task SyncPlatformsAsync_ConfiguredBufferedInterval_IsForwardedToGw()
{
// PR 6.3 — when a deployment dials down MxAccess.PublishingIntervalMs for
// tighter health visibility, the probe watcher must forward that interval
// through SubscribeBulk so the gw publishes ScanState changes at the
// configured cadence.
var subscriber = new FakeSubscriber();
using var watcher = new PerPlatformProbeWatcher(
subscriber, new HostStatusAggregator(),
bufferedUpdateIntervalMs: 250);
await watcher.SyncPlatformsAsync(["PlatformA"], CancellationToken.None);
subscriber.SubscribeIntervalsMs.ShouldHaveSingleItem().ShouldBe(250);
}
[Fact]
public void Constructor_RejectsNegativeBufferedInterval()
{
var subscriber = new FakeSubscriber();
Should.Throw<ArgumentOutOfRangeException>(() =>
new PerPlatformProbeWatcher(subscriber, new HostStatusAggregator(), bufferedUpdateIntervalMs: -1));
}
[Fact]
public async Task SyncPlatformsAsync_SameSetTwice_DoesNotResubscribe()
{
var subscriber = new FakeSubscriber();
var aggregator = new HostStatusAggregator();
using var watcher = new PerPlatformProbeWatcher(subscriber, aggregator);
await watcher.SyncPlatformsAsync(["PlatformA"], CancellationToken.None);
await watcher.SyncPlatformsAsync(["PlatformA"], CancellationToken.None);
subscriber.Subscribes.Count.ShouldBe(1);
}
[Fact]
public async Task SyncPlatformsAsync_RemovedPlatforms_AreUnsubscribed_AndDroppedFromAggregator()
{
var subscriber = new FakeSubscriber();
var aggregator = new HostStatusAggregator();
using var watcher = new PerPlatformProbeWatcher(subscriber, aggregator);
await watcher.SyncPlatformsAsync(["A", "B"], CancellationToken.None);
var bHandle = subscriber.HandleByAddress["B.ScanState"];
// Push a value so B is in the aggregator before we remove it.
watcher.OnProbeValueChanged("B.ScanState", true, qualityByte: 192);
aggregator.Snapshot().Any(s => s.HostName == "B").ShouldBeTrue();
await watcher.SyncPlatformsAsync(["A"], CancellationToken.None);
subscriber.Unsubscribes.Count.ShouldBe(1);
subscriber.Unsubscribes[0].ShouldBe(new[] { bHandle });
watcher.WatchedPlatforms.ShouldBe(new[] { "A" });
aggregator.Snapshot().Any(s => s.HostName == "B").ShouldBeFalse();
}
[Theory]
[InlineData(true, (byte)192, HostState.Running)]
[InlineData(false, (byte)192, HostState.Stopped)]
[InlineData(1, (byte)192, HostState.Running)]
[InlineData(0, (byte)192, HostState.Stopped)]
[InlineData("Running", (byte)192, HostState.Running)]
[InlineData("Stopped", (byte)192, HostState.Stopped)]
[InlineData("running", (byte)192, HostState.Running)]
[InlineData(2, (byte)192, HostState.Faulted)] // unknown int
[InlineData("Whatever", (byte)192, HostState.Faulted)] // unknown string
[InlineData(true, (byte)64, HostState.Unknown)] // bad quality wins
[InlineData(true, (byte)0, HostState.Unknown)]
public void DecodeState_TablePins(object? value, byte qualityByte, HostState expected)
{
PerPlatformProbeWatcher.DecodeState(value, qualityByte).ShouldBe(expected);
}
[Fact]
public async Task OnProbeValueChanged_Running_RoutesToAggregator()
{
var subscriber = new FakeSubscriber();
var aggregator = new HostStatusAggregator();
using var watcher = new PerPlatformProbeWatcher(subscriber, aggregator);
await watcher.SyncPlatformsAsync(["PlatformA"], CancellationToken.None);
watcher.OnProbeValueChanged("PlatformA.ScanState", true, qualityByte: 192);
var snap = aggregator.Snapshot().Single(s => s.HostName == "PlatformA");
snap.State.ShouldBe(HostState.Running);
}
[Fact]
public async Task OnProbeValueChanged_BadQuality_RoutesUnknown()
{
var subscriber = new FakeSubscriber();
var aggregator = new HostStatusAggregator();
using var watcher = new PerPlatformProbeWatcher(subscriber, aggregator);
await watcher.SyncPlatformsAsync(["PlatformA"], CancellationToken.None);
watcher.OnProbeValueChanged("PlatformA.ScanState", true, qualityByte: 0);
aggregator.Snapshot().Single(s => s.HostName == "PlatformA").State.ShouldBe(HostState.Unknown);
}
[Fact]
public async Task OnProbeValueChanged_ForeignReference_IsSilentlyDropped()
{
var subscriber = new FakeSubscriber();
var aggregator = new HostStatusAggregator();
using var watcher = new PerPlatformProbeWatcher(subscriber, aggregator);
await watcher.SyncPlatformsAsync(["PlatformA"], CancellationToken.None);
// Reference doesn't end with .ScanState — silently dropped.
watcher.OnProbeValueChanged("PlatformA.SomethingElse", true, qualityByte: 192);
aggregator.Snapshot().Any(s => s.HostName == "PlatformA").ShouldBeFalse();
// Unknown platform — silently dropped.
watcher.OnProbeValueChanged("Stranger.ScanState", true, qualityByte: 192);
aggregator.Snapshot().Any(s => s.HostName == "Stranger").ShouldBeFalse();
}
[Fact]
public async Task Dispose_UnsubscribesAllTrackedPlatforms()
{
var subscriber = new FakeSubscriber();
var aggregator = new HostStatusAggregator();
var watcher = new PerPlatformProbeWatcher(subscriber, aggregator);
await watcher.SyncPlatformsAsync(["A", "B", "C"], CancellationToken.None);
var expectedHandles = new[]
{
subscriber.HandleByAddress["A.ScanState"],
subscriber.HandleByAddress["B.ScanState"],
subscriber.HandleByAddress["C.ScanState"],
};
watcher.Dispose();
subscriber.Unsubscribes.Count.ShouldBe(1);
subscriber.Unsubscribes[0].OrderBy(x => x).ShouldBe(expectedHandles.OrderBy(x => x));
}
}

View File

@@ -0,0 +1,239 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// PR B.1 — pins the EventPump's OnAlarmTransition decode path. Synthetic MxEvents
/// with the new family go in; the pump fires <c>OnAlarmTransition</c> with the
/// decoded payload + mapped severity bucket; data-change subscribers stay
/// untouched.
/// </summary>
public sealed class EventPumpAlarmTests
{
[Fact]
public async Task Dispatches_raise_acknowledge_clear_in_sequence()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
var transitions = new List<GalaxyAlarmTransition>();
var dispatched = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(subscriber, registry, channelCapacity: 16, clientName: "AlarmTest");
pump.OnAlarmTransition += (_, transition) =>
{
lock (transitions)
{
transitions.Add(transition);
if (transitions.Count == 3) dispatched.TrySetResult(true);
}
};
pump.Start();
var raise = new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc);
var ack = raise.AddSeconds(30);
var clear = ack.AddSeconds(60);
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Raise, severity: 750, transitionTime: raise));
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Acknowledge, severity: 750, transitionTime: ack,
originalRaise: raise, operatorUser: "alice", operatorComment: "investigating"));
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Clear, severity: 750, transitionTime: clear,
originalRaise: raise));
var completed = await Task.WhenAny(dispatched.Task, Task.Delay(TimeSpan.FromSeconds(2)));
completed.ShouldBe(dispatched.Task, "all three alarm transitions should dispatch within 2s");
transitions.Count.ShouldBe(3);
transitions[0].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Raise);
transitions[0].SeverityBucket.ShouldBe(AlarmSeverity.Critical);
transitions[0].OpcUaSeverity.ShouldBe(MxAccessSeverityMapper.OpcUaSeverityCritical);
transitions[0].RawMxAccessSeverity.ShouldBe(750);
transitions[0].TransitionTimestampUtc.ShouldBe(raise);
transitions[1].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Acknowledge);
transitions[1].OperatorUser.ShouldBe("alice");
transitions[1].OperatorComment.ShouldBe("investigating");
transitions[1].OriginalRaiseTimestampUtc.ShouldBe(raise);
transitions[2].TransitionKind.ShouldBe(GalaxyAlarmTransitionKind.Clear);
transitions[2].OriginalRaiseTimestampUtc.ShouldBe(raise);
}
[Fact]
public async Task Drops_alarm_event_with_unspecified_transition_kind()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
var transitions = new List<GalaxyAlarmTransition>();
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "AlarmTest");
pump.OnAlarmTransition += (_, transition) => transitions.Add(transition);
pump.Start();
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Unspecified, severity: 100,
transitionTime: DateTime.UtcNow));
// Give the pump a beat to drain the channel.
await Task.Delay(150);
transitions.ShouldBeEmpty("alarm transitions with Unspecified kind are decoder failures and must not fire OnAlarmTransition");
}
[Fact]
public async Task Drops_alarm_event_with_missing_body()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
var transitions = new List<GalaxyAlarmTransition>();
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "AlarmTest");
pump.OnAlarmTransition += (_, transition) => transitions.Add(transition);
pump.Start();
// Family marked as alarm-transition but body left empty (worker version skew /
// malformed event). Production should count + drop, not throw.
await subscriber.EmitRawAsync(new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
WorkerSequence = 42,
});
await Task.Delay(150);
transitions.ShouldBeEmpty();
}
[Fact]
public async Task Mixed_data_change_and_alarm_events_dispatch_independently()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tank01.Level", ItemHandle: 7)]);
var dataChanges = new List<DataChangeEventArgs>();
var alarms = new List<GalaxyAlarmTransition>();
var bothSeen = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(subscriber, registry, channelCapacity: 16, clientName: "MixedTest");
pump.OnDataChange += (_, args) =>
{
lock (dataChanges)
{
dataChanges.Add(args);
if (dataChanges.Count >= 1 && alarms.Count >= 1) bothSeen.TrySetResult(true);
}
};
pump.OnAlarmTransition += (_, transition) =>
{
lock (alarms)
{
alarms.Add(transition);
if (dataChanges.Count >= 1 && alarms.Count >= 1) bothSeen.TrySetResult(true);
}
};
pump.Start();
await subscriber.EmitAsync(itemHandle: 7, value: 41.0);
await subscriber.EmitAlarmAsync(NewAlarm("Tank01.Level.HiHi",
AlarmTransitionKind.Raise, severity: 600, transitionTime: DateTime.UtcNow));
var completed = await Task.WhenAny(bothSeen.Task, Task.Delay(TimeSpan.FromSeconds(2)));
completed.ShouldBe(bothSeen.Task);
dataChanges.Count.ShouldBe(1);
alarms.Count.ShouldBe(1);
alarms[0].SeverityBucket.ShouldBe(AlarmSeverity.High);
}
[Fact]
public async Task Filters_out_unsupported_event_families()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
var transitions = new List<GalaxyAlarmTransition>();
await using var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "FilterTest");
pump.OnAlarmTransition += (_, transition) => transitions.Add(transition);
pump.Start();
// OnWriteComplete and OperationComplete should be silently dropped.
await subscriber.EmitRawAsync(new MxEvent { Family = MxEventFamily.OnWriteComplete });
await subscriber.EmitRawAsync(new MxEvent { Family = MxEventFamily.OperationComplete });
await Task.Delay(150);
transitions.ShouldBeEmpty();
}
private static MxEvent NewAlarm(
string fullReference,
AlarmTransitionKind kind,
int severity,
DateTime transitionTime,
DateTime? originalRaise = null,
string operatorUser = "",
string operatorComment = "")
{
var body = new OnAlarmTransitionEvent
{
AlarmFullReference = fullReference,
SourceObjectReference = fullReference.Split('.')[0],
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = kind,
Severity = severity,
TransitionTimestamp = Timestamp.FromDateTime(transitionTime),
OperatorUser = operatorUser,
OperatorComment = operatorComment,
Category = "Process",
Description = "Tank 01 high-high level",
};
if (originalRaise is { } orts)
{
body.OriginalRaiseTimestamp = Timestamp.FromDateTime(orts);
}
return new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnAlarmTransition = body,
};
}
private sealed class ManualSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
public ValueTask EmitAsync(int itemHandle, double value) =>
_stream.Writer.WriteAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = itemHandle,
Value = new MxValue { DoubleValue = value },
Quality = 192,
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
});
public ValueTask EmitAlarmAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
public ValueTask EmitRawAsync(MxEvent ev) => _stream.Writer.WriteAsync(ev);
}
}

View File

@@ -0,0 +1,179 @@
using System.Diagnostics.Metrics;
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// PR 6.2 — pins the EventPump's bounded-channel + drop-newest behavior. We
/// hold the dispatch loop with a slow handler so the channel fills, then verify
/// the producer keeps reading from the gw stream and increments the
/// <c>galaxy.events.dropped</c> counter rather than blocking.
/// </summary>
public sealed class EventPumpBoundedChannelTests
{
[Fact]
public async Task Drops_newest_when_channel_fills_and_records_metric()
{
var counters = StartMeterCapture();
try
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", ItemHandle: 7)]);
// Tiny channel + slow handler ⇒ producer hits FullMode.Wait → TryWrite false
// for every overflow event.
var dispatchGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
await using var pump = new EventPump(
subscriber, registry, channelCapacity: 2, clientName: "PumpTest");
pump.OnDataChange += async (_, _) =>
{
// Block the dispatch loop until we've shoved enough events through to
// overflow the bounded channel. Consume the gate exactly once.
await dispatchGate.Task.ConfigureAwait(false);
};
pump.Start();
const int totalEvents = 10;
for (var i = 0; i < totalEvents; i++)
{
await subscriber.EmitAsync(itemHandle: 7, value: i);
}
// Give the producer a beat to run TryWrite for every event.
await Task.Delay(150);
// Capacity 2 + 1 in-flight in the dispatcher = 3 may have been accepted; the
// remainder should have hit the dropped counter. Don't pin exact counts —
// the scheduler can interleave; pin the invariants instead.
counters.Received.ShouldBeGreaterThanOrEqualTo(totalEvents);
counters.Dropped.ShouldBeGreaterThan(0,
"with capacity=2 and a held dispatcher we must drop at least one of 10 events");
(counters.Received).ShouldBe(counters.Dispatched + counters.Dropped + counters.InFlight,
"received = dispatched + dropped + (events still queued)");
// Release the dispatcher so DisposeAsync can drain cleanly.
dispatchGate.TrySetResult();
}
finally
{
counters.Dispose();
}
}
[Fact]
public async Task Throws_when_channelCapacity_is_invalid()
{
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
Should.Throw<ArgumentOutOfRangeException>(() =>
new EventPump(subscriber, registry, channelCapacity: 0));
Should.Throw<ArgumentOutOfRangeException>(() =>
new EventPump(subscriber, registry, channelCapacity: -1));
await Task.CompletedTask;
}
[Fact]
public async Task Tags_metrics_with_client_name_for_multi_driver_hosts()
{
var captured = new List<(string Instrument, KeyValuePair<string, object?>[] Tags)>();
using var listener = new MeterListener();
listener.InstrumentPublished = (instr, l) =>
{
if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr);
};
listener.SetMeasurementEventCallback<long>((instr, _, tags, _) =>
{
captured.Add((instr.Name, tags.ToArray()));
});
listener.Start();
var subscriber = new ManualSubscriber();
var registry = new SubscriptionRegistry();
registry.Register(1, [new TagBinding("Tag.A", ItemHandle: 7)]);
await using (var pump = new EventPump(subscriber, registry, channelCapacity: 4, clientName: "Driver-X"))
{
pump.Start();
await subscriber.EmitAsync(7, 42.0);
await Task.Delay(100);
listener.RecordObservableInstruments();
}
// The static Meter is shared across all EventPump instances in the test
// assembly; xUnit may run other pump tests in parallel and their
// measurements land on the same listener. Filter to our pump's tag value.
var ours = captured
.Where(c => c.Tags.Any(t => t.Key == "galaxy.client"
&& string.Equals((string?)t.Value, "Driver-X", StringComparison.Ordinal)))
.ToList();
ours.ShouldNotBeEmpty(
"at least one measurement from this test's pump must carry galaxy.client=Driver-X");
ours.ShouldContain(c => c.Instrument == "galaxy.events.received");
}
private static CounterCapture StartMeterCapture()
{
var capture = new CounterCapture();
var listener = new MeterListener();
listener.InstrumentPublished = (instr, l) =>
{
if (instr.Meter.Name == EventPump.MeterName) l.EnableMeasurementEvents(instr);
};
listener.SetMeasurementEventCallback<long>((instr, value, _, _) =>
{
switch (instr.Name)
{
case "galaxy.events.received": Interlocked.Add(ref capture._received, value); break;
case "galaxy.events.dispatched": Interlocked.Add(ref capture._dispatched, value); break;
case "galaxy.events.dropped": Interlocked.Add(ref capture._dropped, value); break;
}
});
listener.Start();
capture.Listener = listener;
return capture;
}
private sealed class CounterCapture : IDisposable
{
public MeterListener? Listener;
internal long _received, _dispatched, _dropped;
public long Received => Interlocked.Read(ref _received);
public long Dispatched => Interlocked.Read(ref _dispatched);
public long Dropped => Interlocked.Read(ref _dropped);
public long InFlight => Math.Max(0, Received - Dispatched - Dropped);
public void Dispose() => Listener?.Dispose();
}
private sealed class ManualSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
public ValueTask EmitAsync(int itemHandle, double value) =>
_stream.Writer.WriteAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = itemHandle,
Value = new MxValue { DoubleValue = value },
Quality = 192,
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
});
}
}

View File

@@ -0,0 +1,151 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Tests for <see cref="GalaxyDriver"/>'s <c>IReadable</c> wiring. PR 4.2 ships the
/// abstraction (<see cref="IGalaxyDataReader"/>) and the wiring; PR 4.4 supplies the
/// production gateway-backed reader. These tests verify the wiring against a fake
/// reader plus the explicit "no reader → NotSupportedException" fallback that protects
/// deployments running on this PR from silently producing wrong reads.
/// </summary>
public sealed class GalaxyDriverReadTests
{
private static GalaxyDriverOptions Opts() => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
private sealed class FakeReader : IGalaxyDataReader
{
public IReadOnlyList<string>? LastRequest { get; private set; }
public Func<IReadOnlyList<string>, IReadOnlyList<DataValueSnapshot>> Decide { get; set; } =
tags => tags.Select(t => new DataValueSnapshot(
Value: t,
StatusCode: StatusCodeMap.Good,
SourceTimestampUtc: DateTime.UtcNow,
ServerTimestampUtc: DateTime.UtcNow)).ToArray();
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
LastRequest = fullReferences;
return Task.FromResult(Decide(fullReferences));
}
}
[Fact]
public async Task ReadAsync_RoutesThroughInjectedReader()
{
var reader = new FakeReader();
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
var result = await driver.ReadAsync(["Tank1.Level", "Tank2.Level"], CancellationToken.None);
reader.LastRequest.ShouldBe(new[] { "Tank1.Level", "Tank2.Level" });
result.Count.ShouldBe(2);
result[0].Value.ShouldBe("Tank1.Level");
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
}
[Fact]
public async Task ReadAsync_EmptyRequest_ReturnsEmpty_WithoutCallingReader()
{
var reader = new FakeReader();
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
var result = await driver.ReadAsync([], CancellationToken.None);
result.ShouldBeEmpty();
reader.LastRequest.ShouldBeNull();
}
[Fact]
public async Task ReadAsync_NoSeams_AndNoProductionRuntime_Throws()
{
// Construction without seams + without InitializeAsync gives a driver where
// _dataReader and _subscriber are both null. The follow-up read path can't
// synthesise a Read without one, so it surfaces a NotSupportedException
// pointing at the misuse rather than NullRef'ing inside the pump path.
var driver = new GalaxyDriver("g", Opts());
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.ReadAsync(["x"], CancellationToken.None));
ex.Message.ShouldContain("production runtime not built");
}
[Fact]
public async Task ReadAsync_AfterDispose_Throws()
{
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: new FakeReader());
driver.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(() =>
driver.ReadAsync(["x"], CancellationToken.None));
}
[Fact]
public async Task ReadAsync_SubscribeOncePath_ResolvesFromFirstOnDataChange()
{
// Follow-up #1: when no test reader is injected but a subscriber IS, the driver
// synthesises a Read by subscribing, waiting for the first OnDataChange event
// per item handle (gw pushes initial value), then unsubscribing.
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber();
using var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
var readTask = driver.ReadAsync(["Tank.Level"], CancellationToken.None);
// Push the "initial value" event the gw would emit immediately after SubscribeBulk.
await Task.Delay(50); // give SubscribeBulk a beat to register + handler to attach
var itemHandle = subscriber.Map["Tank.Level"];
await subscriber.EmitOnDataChangeAsync(itemHandle, 42.0);
var result = await readTask;
result.Count.ShouldBe(1);
result[0].Value.ShouldBe(42.0);
// Cleanup unsubscribed the live handle.
subscriber.UnsubscribedHandles.ShouldContain(itemHandle);
}
[Fact]
public async Task ReadAsync_SubscribeOncePath_RejectedTagSurfacesAsBadStatus()
{
// gw rejects "Bad" at SubscribeBulk; the read path completes that slot with a
// Bad-status snapshot rather than waiting forever for an event that won't come.
var subscriber = new GalaxyDriverSubscribeTests.FakeSubscriber { Decide = tag => tag != "Bad" };
using var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
var readTask = driver.ReadAsync(["Good", "Bad"], CancellationToken.None);
await Task.Delay(50);
await subscriber.EmitOnDataChangeAsync(subscriber.Map["Good"], 1.0);
var result = await readTask;
result.Count.ShouldBe(2);
result[0].Value.ShouldBe(1.0);
result[1].StatusCode.ShouldBe(0x80000000u); // Bad
}
[Fact]
public async Task ReadAsync_PreservesReaderStatusCodes()
{
var reader = new FakeReader
{
Decide = tags => new DataValueSnapshot[]
{
new(42.0, StatusCodeMap.Good, DateTime.UtcNow, DateTime.UtcNow),
new(null, StatusCodeMap.BadNotConnected, null, DateTime.UtcNow),
},
};
var driver = new GalaxyDriver("g", Opts(), hierarchySource: null, dataReader: reader);
var result = await driver.ReadAsync(["a", "b"], CancellationToken.None);
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
result[1].StatusCode.ShouldBe(StatusCodeMap.BadNotConnected);
}
}

View File

@@ -0,0 +1,284 @@
using System.Threading.Channels;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// End-to-end tests for <see cref="GalaxyDriver"/>'s ISubscribable wiring +
/// <see cref="EventPump"/>. The fake subscriber replays a controlled stream of
/// <see cref="MxEvent"/>s; the test asserts the driver's <c>OnDataChange</c> fans
/// out per registered subscription.
/// </summary>
public sealed class GalaxyDriverSubscribeTests
{
private static GalaxyDriverOptions Opts() => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
internal sealed class FakeSubscriber : IGalaxySubscriber
{
private int _nextHandle = 1;
private readonly Channel<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
public Dictionary<string, int> Map { get; } = new();
public List<int> UnsubscribedHandles { get; } = [];
public List<int> BufferedIntervalsCalled { get; } = [];
public Func<string, bool> Decide { get; set; } = _ => true;
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
BufferedIntervalsCalled.Add(bufferedUpdateIntervalMs);
var results = new List<SubscribeResult>(fullReferences.Count);
foreach (var fullRef in fullReferences)
{
if (Decide(fullRef))
{
var handle = Interlocked.Increment(ref _nextHandle);
Map[fullRef] = handle;
results.Add(new SubscribeResult
{
TagAddress = fullRef,
ItemHandle = handle,
WasSuccessful = true,
});
}
else
{
results.Add(new SubscribeResult
{
TagAddress = fullRef,
ItemHandle = 0,
WasSuccessful = false,
ErrorMessage = "rejected by fake",
});
}
}
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
UnsubscribedHandles.AddRange(itemHandles);
return Task.CompletedTask;
}
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _events.Reader.ReadAllAsync(cancellationToken);
public ValueTask EmitOnDataChangeAsync(int itemHandle, double value, byte quality = 192) =>
_events.Writer.WriteAsync(new MxEvent
{
Family = MxEventFamily.OnDataChange,
ItemHandle = itemHandle,
Value = new MxValue { DoubleValue = value },
Quality = quality,
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
});
public void CompleteEvents() => _events.Writer.Complete();
}
[Fact]
public async Task SubscribeAsync_AllocatesHandle_AndDispatchesValueChange()
{
var subscriber = new FakeSubscriber();
using var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
var captured = new List<DataChangeEventArgs>();
driver.OnDataChange += (_, args) => captured.Add(args);
var handle = await driver.SubscribeAsync(["Tank.Level"], TimeSpan.FromSeconds(1), CancellationToken.None);
var itemHandle = subscriber.Map["Tank.Level"];
await subscriber.EmitOnDataChangeAsync(itemHandle, 42.0);
await WaitForAsync(() => captured.Count >= 1);
captured.Count.ShouldBe(1);
captured[0].SubscriptionHandle.ShouldBe(handle);
captured[0].FullReference.ShouldBe("Tank.Level");
((double)captured[0].Snapshot.Value!).ShouldBe(42.0);
}
[Fact]
public async Task SubscribeAsync_TwoSubscriptions_SameTag_FanOutOnePerSubscription()
{
var subscriber = new FakeSubscriber();
using var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
var captured = new List<DataChangeEventArgs>();
driver.OnDataChange += (_, args) => captured.Add(args);
var handle1 = await driver.SubscribeAsync(["A"], TimeSpan.FromSeconds(1), CancellationToken.None);
var handle2 = await driver.SubscribeAsync(["A"], TimeSpan.FromSeconds(1), CancellationToken.None);
// Both subscriptions resolved the same FullRef. The fake gives each its own
// itemHandle (Map["A"] gets overwritten), so we use the latest mapping for the
// second subscription's expected delivery; the first subscription's binding
// points at an item handle the gateway fake hasn't emitted on. To exercise the
// fan-out, register both subs against the SAME handle (matches the gw's "one
// handle per (server, tag) pair" pattern in production where SubscribeBulk
// returns the existing handle for an already-AddItem'd tag).
subscriber.Map["A"].ShouldBeGreaterThan(0);
// Synthesize an event against handle 2 (which is also tracked under sub 2).
// Fan-out for the same tag is best validated at the registry level — the
// SubscriptionRegistryTests cover the multi-sub-same-handle case directly.
await subscriber.EmitOnDataChangeAsync(subscriber.Map["A"], 7.0);
await WaitForAsync(() => captured.Count >= 1);
// At least one delivery — depending on which subscription owns the handle,
// either handle1 or handle2 receives. The fan-out invariant (a single handle
// delivers to every subscription that registered it) is pinned in
// SubscriptionRegistryTests; here we just confirm the wiring works.
captured.ShouldNotBeEmpty();
captured[0].SubscriptionHandle.DiagnosticId.ShouldStartWith("galaxy-sub-");
// Either handle1 or handle2 must match the captured handle's id.
var captured0Id = ((GalaxySubscriptionHandle)captured[0].SubscriptionHandle).SubscriptionId;
var allowed = new[] {
((GalaxySubscriptionHandle)handle1).SubscriptionId,
((GalaxySubscriptionHandle)handle2).SubscriptionId,
};
allowed.ShouldContain(captured0Id);
}
[Fact]
public async Task SubscribeAsync_FailedTag_DoesNotDispatchEvents()
{
var subscriber = new FakeSubscriber { Decide = tag => tag != "Bad" };
using var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
var captured = new List<DataChangeEventArgs>();
driver.OnDataChange += (_, args) => captured.Add(args);
await driver.SubscribeAsync(["Good", "Bad"], TimeSpan.FromSeconds(1), CancellationToken.None);
// Good has an itemHandle; Bad doesn't (item handle 0). An event with handle 0
// must NOT be dispatched (no subscribers registered against it).
await subscriber.EmitOnDataChangeAsync(itemHandle: 0, value: 999.0);
await Task.Delay(50); // give the pump a chance
captured.ShouldBeEmpty();
}
[Fact]
public async Task UnsubscribeAsync_RemovesRegistration_AndCallsGwUnsubscribe()
{
var subscriber = new FakeSubscriber();
using var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
var handle = await driver.SubscribeAsync(["X"], TimeSpan.FromSeconds(1), CancellationToken.None);
var itemHandle = subscriber.Map["X"];
await driver.UnsubscribeAsync(handle, CancellationToken.None);
subscriber.UnsubscribedHandles.ShouldContain(itemHandle);
// Subsequent events for the dropped handle don't dispatch.
var captured = new List<DataChangeEventArgs>();
driver.OnDataChange += (_, args) => captured.Add(args);
await subscriber.EmitOnDataChangeAsync(itemHandle, 11.0);
await Task.Delay(50);
captured.ShouldBeEmpty();
}
[Fact]
public async Task UnsubscribeAsync_UnknownHandle_NoOp()
{
var subscriber = new FakeSubscriber();
using var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
// Handle issued by a different driver shape — must throw (it's a programming
// error, not a recoverable runtime condition).
var foreignHandle = new ForeignHandle();
await Should.ThrowAsync<ArgumentException>(() =>
driver.UnsubscribeAsync(foreignHandle, CancellationToken.None));
}
[Fact]
public async Task SubscribeAsync_NoSubscriber_Throws()
{
using var driver = new GalaxyDriver("g", Opts());
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.SubscribeAsync(["x"], TimeSpan.FromSeconds(1), CancellationToken.None));
ex.Message.ShouldContain("PR 4.W");
}
[Fact]
public async Task SubscribeAsync_FallsBackToConfiguredInterval_WhenCallerPassesZero()
{
// PR 6.3 — when the caller doesn't set a publishing interval (TimeSpan.Zero),
// the driver substitutes MxAccess.PublishingIntervalMs from its options.
var subscriber = new FakeSubscriber();
var opts = new GalaxyDriverOptions(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A", PublishingIntervalMs: 750),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
using var driver = new GalaxyDriver(
"g", opts, hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
await driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None);
subscriber.BufferedIntervalsCalled.ShouldHaveSingleItem().ShouldBe(750);
}
[Fact]
public async Task SubscribeAsync_RespectsCallerInterval_WhenNonZero()
{
// The caller's publishingInterval wins when explicitly set — the configured
// option only applies as a fallback for "no-preference" callers.
var subscriber = new FakeSubscriber();
var opts = new GalaxyDriverOptions(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A", PublishingIntervalMs: 750),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
using var driver = new GalaxyDriver(
"g", opts, hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
await driver.SubscribeAsync(["Tag.A"], TimeSpan.FromMilliseconds(250), CancellationToken.None);
subscriber.BufferedIntervalsCalled.ShouldHaveSingleItem().ShouldBe(250);
}
[Fact]
public async Task SubscribeAsync_EmptyTagList_ReturnsHandleWithoutCallingGw()
{
var subscriber = new FakeSubscriber();
using var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: null, subscriber: subscriber);
var handle = await driver.SubscribeAsync([], TimeSpan.FromSeconds(1), CancellationToken.None);
handle.ShouldNotBeNull();
subscriber.Map.ShouldBeEmpty();
}
private sealed class ForeignHandle : ISubscriptionHandle
{
public string DiagnosticId => "foreign-x";
}
private static async Task WaitForAsync(Func<bool> predicate, int timeoutMs = 1000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (predicate()) return;
await Task.Delay(10);
}
predicate().ShouldBeTrue("Predicate did not become true within timeout.");
}
}

View File

@@ -0,0 +1,175 @@
using MxGateway.Contracts.Proto.Galaxy;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Tests for <see cref="GalaxyDriver"/>'s <c>IWritable</c> wiring. Verifies the
/// SecurityClassification per-tag map gets populated during Discovery and routes the
/// subsequent WriteAsync calls to the right gateway command (Write vs WriteSecured).
/// The actual Write / WriteSecured invocation is tested separately at the
/// <see cref="GatewayGalaxyDataWriter"/> level — this test class focuses on the
/// driver-side wiring.
/// </summary>
public sealed class GalaxyDriverWriteTests
{
private static GalaxyDriverOptions Opts() => new(
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
new GalaxyMxAccessOptions("OtOpcUa-A"),
new GalaxyRepositoryOptions(),
new GalaxyReconnectOptions());
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
{
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
=> Task.FromResult(objects);
}
private sealed class FakeBuilder : IAddressSpaceBuilder
{
public List<DriverAttributeInfo> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add(attributeInfo);
return new FakeHandle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class FakeHandle(string fullRef) : IVariableHandle
{
public string FullReference { get; } = fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
private sealed class NoopSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}
private sealed class FakeWriter : IGalaxyDataWriter
{
public List<(string FullRef, object? Value, SecurityClassification Resolved)> Calls { get; } = [];
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
Func<string, SecurityClassification> securityResolver,
CancellationToken cancellationToken)
{
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
{
Calls.Add((writes[i].FullReference, writes[i].Value, securityResolver(writes[i].FullReference)));
results[i] = new WriteResult(StatusCodeMap.Good);
}
return Task.FromResult<IReadOnlyList<WriteResult>>(results);
}
}
private static GalaxyAttribute Attr(string name, int sec)
=> new() { AttributeName = name, MxDataType = 2 /*Float32*/, SecurityClassification = sec };
private static GalaxyObject Obj(string tag, params GalaxyAttribute[] attrs)
{
var o = new GalaxyObject { TagName = tag, ContainedName = tag };
o.Attributes.AddRange(attrs);
return o;
}
[Fact]
public async Task WriteAsync_RoutesThroughInjectedWriter_AndPropagatesValues()
{
var src = new FakeHierarchySource([
Obj("Tank1_Level", Attr("PV", sec: 0 /*FreeAccess*/), Attr("SP", sec: 1 /*Operate*/)),
]);
var writer = new FakeWriter();
var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: src, dataReader: null, dataWriter: writer);
var builder = new FakeBuilder();
await driver.DiscoverAsync(builder, CancellationToken.None);
await driver.WriteAsync([
new WriteRequest("Tank1_Level.PV", 42.0),
new WriteRequest("Tank1_Level.SP", 50.0),
], CancellationToken.None);
writer.Calls.Count.ShouldBe(2);
writer.Calls[0].Resolved.ShouldBe(SecurityClassification.FreeAccess);
writer.Calls[1].Resolved.ShouldBe(SecurityClassification.Operate);
}
[Theory]
[InlineData(0, SecurityClassification.FreeAccess)]
[InlineData(1, SecurityClassification.Operate)]
[InlineData(2, SecurityClassification.SecuredWrite)]
[InlineData(3, SecurityClassification.VerifiedWrite)]
[InlineData(4, SecurityClassification.Tune)]
[InlineData(5, SecurityClassification.Configure)]
[InlineData(6, SecurityClassification.ViewOnly)]
public async Task WriteAsync_ResolvesEverySecurityClassification_FromDiscovery(int mxSec, SecurityClassification expected)
{
var src = new FakeHierarchySource([
Obj("Tank", Attr("PV", sec: mxSec)),
]);
var writer = new FakeWriter();
var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: src, dataReader: null, dataWriter: writer);
await driver.DiscoverAsync(new FakeBuilder(), CancellationToken.None);
await driver.WriteAsync([new WriteRequest("Tank.PV", 1.0)], CancellationToken.None);
writer.Calls[0].Resolved.ShouldBe(expected);
}
[Fact]
public async Task WriteAsync_UnknownTag_ResolvesToFreeAccess_DefaultsToWrite()
{
var writer = new FakeWriter();
var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: writer);
// No DiscoverAsync call → classification map is empty → resolver returns FreeAccess
// for any tag the gateway might attempt. WriteAsync must not throw on unknown tags.
await driver.WriteAsync([new WriteRequest("Random.Tag", 1.0)], CancellationToken.None);
writer.Calls[0].Resolved.ShouldBe(SecurityClassification.FreeAccess);
}
[Fact]
public async Task WriteAsync_EmptyRequest_ReturnsEmpty_WithoutCallingWriter()
{
var writer = new FakeWriter();
var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: writer);
var result = await driver.WriteAsync([], CancellationToken.None);
result.ShouldBeEmpty();
writer.Calls.ShouldBeEmpty();
}
[Fact]
public async Task WriteAsync_NoWriter_Throws_PointingAtPR44()
{
var driver = new GalaxyDriver("g", Opts());
var ex = await Should.ThrowAsync<NotSupportedException>(() =>
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
ex.Message.ShouldContain("PR 4.4");
}
[Fact]
public async Task WriteAsync_AfterDispose_Throws()
{
var writer = new FakeWriter();
var driver = new GalaxyDriver(
"g", Opts(), hierarchySource: null, dataReader: null, dataWriter: writer);
driver.Dispose();
await Should.ThrowAsync<ObjectDisposedException>(() =>
driver.WriteAsync([new WriteRequest("x", 1)], CancellationToken.None));
}
}

View File

@@ -0,0 +1,179 @@
using System.Diagnostics;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// PR 6.1 — pins that every gw-facing call produces a span on the
/// <c>ZB.MOM.WW.OtOpcUa.Driver.Galaxy</c> ActivitySource. We listen via
/// <see cref="ActivityListener"/> rather than asserting on internal state, so the
/// tests double as documentation of the listener-side contract.
/// </summary>
public sealed class GalaxyTelemetryTests
{
/// <summary>Subscribes an ActivityListener for the test, captures each spawned activity.</summary>
private static (ActivityListener Listener, List<Activity> Captured) StartCapture()
{
var captured = new List<Activity>();
var listener = new ActivityListener
{
ShouldListenTo = src => src.Name == GalaxyTelemetry.ActivitySourceName,
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = activity => captured.Add(activity),
};
ActivitySource.AddActivityListener(listener);
return (listener, captured);
}
[Fact]
public async Task TracedGalaxySubscriber_emits_subscribe_bulk_span_with_tag_count()
{
var (listener, captured) = StartCapture();
try
{
var inner = new FakeSubscriber();
var sut = new TracedGalaxySubscriber(inner, "OtOpcUa-Test");
await sut.SubscribeBulkAsync(["A", "B", "C"], 500, CancellationToken.None);
var span = captured.ShouldHaveSingleItem();
span.OperationName.ShouldBe("galaxy.subscribe_bulk");
span.GetTagItem("galaxy.client").ShouldBe("OtOpcUa-Test");
span.GetTagItem("galaxy.tag_count").ShouldBe(3);
span.GetTagItem("galaxy.buffered_interval_ms").ShouldBe(500);
span.GetTagItem("galaxy.success_count").ShouldBe(3);
}
finally { listener.Dispose(); }
}
[Fact]
public async Task TracedGalaxySubscriber_records_error_and_rethrows_on_failure()
{
var (listener, captured) = StartCapture();
try
{
var sut = new TracedGalaxySubscriber(new ThrowingSubscriber(), "OtOpcUa-Test");
await Should.ThrowAsync<InvalidOperationException>(() =>
sut.SubscribeBulkAsync(["A"], 0, CancellationToken.None));
var span = captured.ShouldHaveSingleItem();
span.Status.ShouldBe(ActivityStatusCode.Error);
span.GetTagItem("exception.type").ShouldBe(typeof(InvalidOperationException).FullName);
}
finally { listener.Dispose(); }
}
[Fact]
public async Task TracedGalaxyDataWriter_tags_secured_write_count()
{
var (listener, captured) = StartCapture();
try
{
var inner = new RecordingWriter();
var sut = new TracedGalaxyDataWriter(inner, "OtOpcUa-Test");
var requests = new[]
{
new WriteRequest("FreeTag", 1.0),
new WriteRequest("OperateTag", 2.0),
new WriteRequest("TuneTag", 3.0),
new WriteRequest("ConfigTag", 4.0),
};
SecurityClassification Resolver(string fullRef) => fullRef switch
{
"FreeTag" => SecurityClassification.FreeAccess,
"OperateTag" => SecurityClassification.Operate,
"TuneTag" => SecurityClassification.Tune,
"ConfigTag" => SecurityClassification.Configure,
_ => SecurityClassification.FreeAccess,
};
await sut.WriteAsync(requests, Resolver, CancellationToken.None);
var span = captured.ShouldHaveSingleItem();
span.OperationName.ShouldBe("galaxy.write");
span.GetTagItem("galaxy.tag_count").ShouldBe(4);
span.GetTagItem("galaxy.secured_write_count").ShouldBe(2); // Tune + Configure
}
finally { listener.Dispose(); }
}
[Fact]
public async Task TracedGalaxyHierarchySource_tags_object_count()
{
var (listener, captured) = StartCapture();
try
{
var sut = new TracedGalaxyHierarchySource(new FakeHierarchy(), "OtOpcUa-Test");
var hierarchy = await sut.GetHierarchyAsync(CancellationToken.None);
hierarchy.Count.ShouldBe(2);
var span = captured.ShouldHaveSingleItem();
span.OperationName.ShouldBe("galaxy.get_hierarchy");
span.GetTagItem("galaxy.object_count").ShouldBe(2);
}
finally { listener.Dispose(); }
}
private sealed class FakeSubscriber : IGalaxySubscriber
{
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>(
fullReferences.Select((r, i) => new SubscribeResult
{
TagAddress = r,
ItemHandle = i + 1,
WasSuccessful = true,
}).ToList());
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
}
private sealed class ThrowingSubscriber : IGalaxySubscriber
{
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> throw new InvalidOperationException("gw down");
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
yield break;
}
}
private sealed class RecordingWriter : IGalaxyDataWriter
{
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
Func<string, SecurityClassification> securityResolver,
CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<WriteResult>>(
writes.Select(_ => new WriteResult(0u)).ToList());
}
private sealed class FakeHierarchy : IGalaxyHierarchySource
{
public Task<IReadOnlyList<MxGateway.Contracts.Proto.Galaxy.GalaxyObject>> GetHierarchyAsync(
CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<MxGateway.Contracts.Proto.Galaxy.GalaxyObject>>(
[new(), new()]);
}
}

View File

@@ -0,0 +1,43 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Pins the four-bucket MxAccess severity → (AlarmSeverity, OPC UA numeric) ladder.
/// Customers see no surprise re-classification when the v2 path takes over from
/// v1's sub-attribute synthesis: the bucket boundaries match v1's
/// <c>GalaxyAlarmTracker</c> per <c>docs/v1/AlarmTracking.md</c>.
/// </summary>
public sealed class MxAccessSeverityMapperTests
{
[Theory]
[InlineData(0, AlarmSeverity.Low, MxAccessSeverityMapper.OpcUaSeverityLow)]
[InlineData(1, AlarmSeverity.Low, MxAccessSeverityMapper.OpcUaSeverityLow)]
[InlineData(249, AlarmSeverity.Low, MxAccessSeverityMapper.OpcUaSeverityLow)]
[InlineData(250, AlarmSeverity.Medium, MxAccessSeverityMapper.OpcUaSeverityMedium)]
[InlineData(499, AlarmSeverity.Medium, MxAccessSeverityMapper.OpcUaSeverityMedium)]
[InlineData(500, AlarmSeverity.High, MxAccessSeverityMapper.OpcUaSeverityHigh)]
[InlineData(749, AlarmSeverity.High, MxAccessSeverityMapper.OpcUaSeverityHigh)]
[InlineData(750, AlarmSeverity.Critical, MxAccessSeverityMapper.OpcUaSeverityCritical)]
[InlineData(999, AlarmSeverity.Critical, MxAccessSeverityMapper.OpcUaSeverityCritical)]
[InlineData(int.MaxValue, AlarmSeverity.Critical, MxAccessSeverityMapper.OpcUaSeverityCritical)]
public void Map_assigns_expected_bucket(int rawMxAccessSeverity, AlarmSeverity expectedBucket, int expectedOpcUaSeverity)
{
var (bucket, opcUa) = MxAccessSeverityMapper.Map(rawMxAccessSeverity);
bucket.ShouldBe(expectedBucket);
opcUa.ShouldBe(expectedOpcUaSeverity);
}
[Fact]
public void Map_clamps_negative_severities_into_low_bucket()
{
var (bucket, opcUa) = MxAccessSeverityMapper.Map(-100);
bucket.ShouldBe(AlarmSeverity.Low);
opcUa.ShouldBe(MxAccessSeverityMapper.OpcUaSeverityLow);
}
}

View File

@@ -0,0 +1,107 @@
using Google.Protobuf;
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Round-trip tests for <see cref="MxValueDecoder"/>. Each scenario constructs a
/// gateway-style <see cref="MxValue"/>, decodes, and asserts the boxed CLR value
/// matches the expected type and value.
/// </summary>
public sealed class MxValueDecoderTests
{
[Fact]
public void Decode_Null_ReturnsNull()
{
MxValueDecoder.Decode(null).ShouldBeNull();
}
[Fact]
public void Decode_IsNullFlag_ReturnsNull()
{
var v = new MxValue { IsNull = true };
MxValueDecoder.Decode(v).ShouldBeNull();
}
[Fact]
public void Decode_Bool() => MxValueDecoder.Decode(new MxValue { BoolValue = true }).ShouldBe(true);
[Fact]
public void Decode_Int32() => MxValueDecoder.Decode(new MxValue { Int32Value = -42 }).ShouldBe(-42);
[Fact]
public void Decode_Int64() => MxValueDecoder.Decode(new MxValue { Int64Value = 123456789012L }).ShouldBe(123456789012L);
[Fact]
public void Decode_Float() => MxValueDecoder.Decode(new MxValue { FloatValue = 3.14f }).ShouldBe(3.14f);
[Fact]
public void Decode_Double() => MxValueDecoder.Decode(new MxValue { DoubleValue = 2.71828 }).ShouldBe(2.71828);
[Fact]
public void Decode_String() => MxValueDecoder.Decode(new MxValue { StringValue = "hello" }).ShouldBe("hello");
[Fact]
public void Decode_Timestamp_ReturnsUtcDateTime()
{
var when = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc);
var v = new MxValue { TimestampValue = Timestamp.FromDateTime(when) };
MxValueDecoder.Decode(v).ShouldBe(when);
}
[Fact]
public void Decode_BoolArray()
{
var v = new MxValue
{
ArrayValue = new MxArray
{
BoolValues = new BoolArray(),
},
};
v.ArrayValue.BoolValues.Values.AddRange(new[] { true, false, true });
var decoded = MxValueDecoder.Decode(v);
decoded.ShouldBe(new[] { true, false, true });
}
[Fact]
public void Decode_DoubleArray()
{
var v = new MxValue
{
ArrayValue = new MxArray { DoubleValues = new DoubleArray() },
};
v.ArrayValue.DoubleValues.Values.AddRange(new[] { 1.0, 2.0, 3.5 });
var decoded = MxValueDecoder.Decode(v);
decoded.ShouldBe(new[] { 1.0, 2.0, 3.5 });
}
[Fact]
public void Decode_StringArray()
{
var v = new MxValue
{
ArrayValue = new MxArray { StringValues = new StringArray() },
};
v.ArrayValue.StringValues.Values.AddRange(new[] { "a", "b" });
var decoded = MxValueDecoder.Decode(v);
decoded.ShouldBe(new[] { "a", "b" });
}
[Fact]
public void Decode_RawValue_ReturnsBytes()
{
var bytes = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF };
var v = new MxValue { RawValue = ByteString.CopyFrom(bytes) };
var decoded = (byte[])MxValueDecoder.Decode(v)!;
decoded.ShouldBe(bytes);
}
}

View File

@@ -0,0 +1,126 @@
using Google.Protobuf.WellKnownTypes;
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Tests for <see cref="MxValueEncoder"/>. Pinning each scalar + array case here
/// guards against accidental drift in the IWritable wire format.
/// </summary>
public sealed class MxValueEncoderTests
{
[Fact]
public void Encode_Null_SetsIsNullFlag()
{
var v = MxValueEncoder.Encode(null);
v.IsNull.ShouldBeTrue();
}
[Fact]
public void Encode_Bool() => MxValueEncoder.Encode(true).BoolValue.ShouldBe(true);
[Theory]
[InlineData((sbyte)-5, -5)]
[InlineData((short)-1000, -1000)]
[InlineData((byte)42, 42)]
[InlineData((ushort)42_000, 42_000)]
public void Encode_NarrowSignedAndUnsigned_FitsInInt32(object input, int expected)
{
var v = MxValueEncoder.Encode(input);
v.KindCase.ShouldBe(MxValue.KindOneofCase.Int32Value);
v.Int32Value.ShouldBe(expected);
}
[Fact]
public void Encode_Int32_RoundTrip() => MxValueEncoder.Encode(int.MinValue).Int32Value.ShouldBe(int.MinValue);
[Fact]
public void Encode_Int64_RoundTrip()
{
var v = MxValueEncoder.Encode(long.MaxValue);
v.KindCase.ShouldBe(MxValue.KindOneofCase.Int64Value);
v.Int64Value.ShouldBe(long.MaxValue);
}
[Fact]
public void Encode_UInt32_FitsInInt32() => MxValueEncoder.Encode((uint)int.MaxValue).Int32Value.ShouldBe(int.MaxValue);
[Fact]
public void Encode_Float() => MxValueEncoder.Encode(3.14f).FloatValue.ShouldBe(3.14f);
[Fact]
public void Encode_Double() => MxValueEncoder.Encode(2.71828).DoubleValue.ShouldBe(2.71828);
[Fact]
public void Encode_String() => MxValueEncoder.Encode("hello").StringValue.ShouldBe("hello");
[Fact]
public void Encode_DateTimeUtc()
{
var when = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Utc);
var v = MxValueEncoder.Encode(when);
v.TimestampValue.ShouldNotBeNull();
v.TimestampValue.ToDateTime().ShouldBe(when);
}
[Fact]
public void Encode_DateTimeLocal_ConvertsToUtc()
{
var local = new DateTime(2026, 4, 29, 12, 0, 0, DateTimeKind.Local);
var v = MxValueEncoder.Encode(local);
v.TimestampValue.ToDateTime().ShouldBe(local.ToUniversalTime());
}
[Fact]
public void Encode_BoolArray()
{
var v = MxValueEncoder.Encode(new[] { true, false, true });
v.ArrayValue.BoolValues.Values.ToArray().ShouldBe(new[] { true, false, true });
v.ArrayValue.Dimensions[0].ShouldBe(3u);
}
[Fact]
public void Encode_DoubleArray()
{
var v = MxValueEncoder.Encode(new[] { 1.0, 2.0, 3.5 });
v.ArrayValue.DoubleValues.Values.ToArray().ShouldBe(new[] { 1.0, 2.0, 3.5 });
}
[Fact]
public void Encode_StringArray()
{
var v = MxValueEncoder.Encode(new[] { "a", "b" });
v.ArrayValue.StringValues.Values.ToArray().ShouldBe(new[] { "a", "b" });
}
[Fact]
public void Encode_DateTimeArray_ConvertsAllToUtc()
{
var inputs = new[] { new DateTime(2026, 4, 29, 0, 0, 0, DateTimeKind.Utc) };
var v = MxValueEncoder.Encode(inputs);
v.ArrayValue.TimestampValues.Values[0].ToDateTime().ShouldBe(inputs[0]);
}
[Fact]
public void Encode_UnsupportedType_Throws()
{
Should.Throw<ArgumentException>(() => MxValueEncoder.Encode(new { Foo = 1 }));
}
[Fact]
public void RoundTrip_AllScalarTypes_DecodeMatchesOriginal()
{
// The encoder + decoder must be inverses for every scalar a Galaxy driver might
// hand to a write. This pin-test catches accidental drift in either direction.
object[] inputs = [true, 42, 12345L, 3.14f, 2.71828, "x"];
foreach (var input in inputs)
{
var encoded = MxValueEncoder.Encode(input);
var decoded = MxValueDecoder.Decode(encoded);
decoded.ShouldBe(input);
}
}
}

View File

@@ -0,0 +1,173 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Tests for <see cref="ReconnectSupervisor"/>'s state machine + backoff. Each
/// scenario drives the supervisor with controllable reopen/replay callbacks and
/// observes the resulting state transitions.
/// </summary>
public sealed class ReconnectSupervisorTests
{
private const int WaitMs = 2_000;
private static ReconnectOptions FastBackoff() =>
new(InitialBackoffOverride: TimeSpan.FromMilliseconds(5),
MaxBackoffOverride: TimeSpan.FromMilliseconds(20));
[Fact]
public void InitialState_IsHealthy()
{
using var sup = new ReconnectSupervisor(_ => Task.CompletedTask, _ => Task.CompletedTask, FastBackoff());
sup.CurrentState.ShouldBe(ReconnectSupervisor.State.Healthy);
sup.IsDegraded.ShouldBeFalse();
sup.LastError.ShouldBeNull();
}
[Fact]
public async Task ReportTransportFailure_DrivesThroughReopenReplay_BackToHealthy()
{
var transitions = new List<StateTransition>();
var lockObj = new object();
using var sup = new ReconnectSupervisor(
reopen: _ => Task.CompletedTask,
replay: _ => Task.CompletedTask,
options: FastBackoff());
sup.StateChanged += (_, t) => { lock (lockObj) transitions.Add(t); };
sup.ReportTransportFailure(new IOException("transport drop"));
await sup.WaitForHealthyAsync(new CancellationTokenSource(WaitMs).Token);
// Expected sequence: Healthy → TransportLost → Reopening → Replaying → Healthy.
IReadOnlyList<StateTransition> snapshot;
lock (lockObj) snapshot = [.. transitions];
var states = snapshot.Select(t => t.Next).ToArray();
states.ShouldContain(ReconnectSupervisor.State.TransportLost);
states.ShouldContain(ReconnectSupervisor.State.Reopening);
states.ShouldContain(ReconnectSupervisor.State.Replaying);
states[^1].ShouldBe(ReconnectSupervisor.State.Healthy);
sup.IsDegraded.ShouldBeFalse();
}
[Fact]
public async Task ReopenFailure_RetriesUntilSuccess_StaysInReopeningBetweenAttempts()
{
var attempts = 0;
using var sup = new ReconnectSupervisor(
reopen: _ => { attempts++; return attempts < 3 ? Task.FromException(new IOException("nope")) : Task.CompletedTask; },
replay: _ => Task.CompletedTask,
options: FastBackoff());
sup.ReportTransportFailure(new IOException("kick off"));
await sup.WaitForHealthyAsync(new CancellationTokenSource(WaitMs).Token);
attempts.ShouldBe(3);
sup.CurrentState.ShouldBe(ReconnectSupervisor.State.Healthy);
sup.LastError.ShouldBeNull(); // cleared on Healthy transition
}
[Fact]
public async Task ReplayFailure_RetriesEntireCycle()
{
var reopens = 0;
var replays = 0;
using var sup = new ReconnectSupervisor(
reopen: _ => { reopens++; return Task.CompletedTask; },
replay: _ => { replays++; return replays < 2 ? Task.FromException(new IOException("replay nope")) : Task.CompletedTask; },
options: FastBackoff());
sup.ReportTransportFailure(new IOException("kick off"));
await sup.WaitForHealthyAsync(new CancellationTokenSource(WaitMs).Token);
// First cycle: reopen succeeds, replay fails. Second cycle: both succeed.
reopens.ShouldBe(2);
replays.ShouldBe(2);
sup.CurrentState.ShouldBe(ReconnectSupervisor.State.Healthy);
}
[Fact]
public async Task RepeatedFailureReports_DuringRecovery_DoNotSpawnParallelLoops()
{
var attempts = 0;
using var sup = new ReconnectSupervisor(
reopen: async ct =>
{
attempts++;
await Task.Delay(50, ct).ConfigureAwait(false);
},
replay: _ => Task.CompletedTask,
options: FastBackoff());
sup.ReportTransportFailure(new IOException("first"));
// Fire several more reports while reopen is in flight.
for (var i = 0; i < 5; i++)
{
sup.ReportTransportFailure(new IOException($"rapid-{i}"));
}
await sup.WaitForHealthyAsync(new CancellationTokenSource(WaitMs).Token);
// One Reopen call regardless of how many failures arrived during recovery.
attempts.ShouldBe(1);
}
[Fact]
public async Task LastError_ReflectsMostRecentFailureCause()
{
using var sup = new ReconnectSupervisor(
reopen: _ => Task.FromException(new IOException("reopen broke")),
replay: _ => Task.CompletedTask,
options: new ReconnectOptions(
InitialBackoffOverride: TimeSpan.FromMilliseconds(5),
MaxBackoffOverride: TimeSpan.FromMilliseconds(10)));
sup.ReportTransportFailure(new IOException("initial"));
// Allow the loop to attempt at least twice.
await Task.Delay(100);
sup.LastError.ShouldNotBeNull();
sup.LastError.ShouldContain("reopen broke"); // updates from the loop's failed reopen attempts
}
[Fact]
public async Task Dispose_CancelsRunningRecoveryLoop_Cleanly()
{
var cancelled = false;
var sup = new ReconnectSupervisor(
reopen: async ct =>
{
try { await Task.Delay(10_000, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { cancelled = true; throw; }
},
replay: _ => Task.CompletedTask,
options: FastBackoff());
sup.ReportTransportFailure(new IOException("kick off"));
await Task.Delay(50); // let the loop start the long reopen
Should.NotThrow(() => sup.Dispose());
cancelled.ShouldBeTrue();
}
[Fact]
public void ReportTransportFailure_AfterDispose_Throws()
{
var sup = new ReconnectSupervisor(_ => Task.CompletedTask, _ => Task.CompletedTask, FastBackoff());
sup.Dispose();
Should.Throw<ObjectDisposedException>(() => sup.ReportTransportFailure(new IOException("x")));
}
[Fact]
public async Task WaitForHealthy_ReturnsImmediately_WhenAlreadyHealthy()
{
using var sup = new ReconnectSupervisor(_ => Task.CompletedTask, _ => Task.CompletedTask, FastBackoff());
// No failure reported — should be Healthy from the start.
var deadline = DateTime.UtcNow.AddMilliseconds(50);
await sup.WaitForHealthyAsync(new CancellationTokenSource(50).Token);
DateTime.UtcNow.ShouldBeLessThan(deadline.AddMilliseconds(100)); // returned promptly
}
}

View File

@@ -0,0 +1,98 @@
using MxGateway.Contracts.Proto;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Exhaustive table-driven tests for <see cref="StatusCodeMap"/>. Pinning the byte→uint
/// mapping here protects against accidental drift — every Galaxy deployment that
/// reaches the parity matrix in PR 5.2 depends on these specific OPC UA StatusCode
/// values matching the legacy <c>HistorianQualityMapper</c> output.
/// </summary>
public sealed class StatusCodeMapTests
{
[Theory]
[InlineData((byte)192, 0x00000000u)] // Good
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
[InlineData((byte)64, 0x40000000u)] // Uncertain
[InlineData((byte)68, 0x40A40000u)] // Uncertain_LastUsableValue
[InlineData((byte)80, 0x408D0000u)] // Uncertain_SensorNotAccurate
[InlineData((byte)84, 0x408E0000u)] // Uncertain_EngineeringUnitsExceeded
[InlineData((byte)88, 0x408F0000u)] // Uncertain_SubNormal
[InlineData((byte)0, 0x80000000u)] // Bad
[InlineData((byte)4, 0x80890000u)] // Bad_ConfigurationError
[InlineData((byte)8, 0x808A0000u)] // Bad_NotConnected
[InlineData((byte)12, 0x808B0000u)] // Bad_DeviceFailure
[InlineData((byte)16, 0x808C0000u)] // Bad_SensorFailure
[InlineData((byte)20, 0x80050000u)] // Bad_CommunicationError
[InlineData((byte)24, 0x808D0000u)] // Bad_OutOfService
[InlineData((byte)32, 0x80320000u)] // Bad_WaitingForInitialData
public void FromQualityByte_KnownValues_MapToOpcUaStatusCode(byte input, uint expected)
{
StatusCodeMap.FromQualityByte(input).ShouldBe(expected);
}
[Theory]
[InlineData((byte)200)] // Unknown Good — falls back to category bucket
[InlineData((byte)100)] // Unknown Uncertain
[InlineData((byte)40)] // Unknown Bad
public void FromQualityByte_UnknownValues_FallBackToCategoryBucket(byte input)
{
var mapped = StatusCodeMap.FromQualityByte(input);
if (input >= 192) mapped.ShouldBe(StatusCodeMap.Good);
else if (input >= 64) mapped.ShouldBe(StatusCodeMap.Uncertain);
else mapped.ShouldBe(StatusCodeMap.Bad);
}
[Fact]
public void FromMxStatus_NullStatus_IsGood()
{
StatusCodeMap.FromMxStatus(null).ShouldBe(StatusCodeMap.Good);
}
[Fact]
public void FromMxStatus_SuccessNonZero_IsGood()
{
var s = new MxStatusProxy { Success = 1 };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Good);
}
[Fact]
public void FromMxStatus_SuccessZero_DetailKnown_MapsToSpecificCode()
{
var s = new MxStatusProxy { Success = 0, Detail = 8 /* Bad_NotConnected */ };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadNotConnected);
}
[Fact]
public void FromMxStatus_SuccessZero_DetailZero_DetectedByNonZero_IsCommunicationError()
{
var s = new MxStatusProxy { Success = 0, Detail = 0, RawDetectedBy = 3 };
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadCommunicationError);
}
[Fact]
public void FromMxStatus_SuccessZero_AllZero_IsBad()
{
var s = new MxStatusProxy();
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
}
[Fact]
public void TopByteCategoryBits_StayWithinOpcUaConvention()
{
// Sanity check that every Bad code we mint actually has the Bad top byte (0x80…),
// every Uncertain has 0x40…, every Good has 0x00…. Pins the OPC UA Part 4 invariant.
StatusCodeMap.Good.ShouldBeLessThan(0x40000000u);
StatusCodeMap.GoodLocalOverride.ShouldBeLessThan(0x40000000u);
((StatusCodeMap.Uncertain >> 30) & 0x3u).ShouldBe(1u);
((StatusCodeMap.UncertainLastUsableValue >> 30) & 0x3u).ShouldBe(1u);
((StatusCodeMap.Bad >> 30) & 0x3u).ShouldBe(2u);
((StatusCodeMap.BadNotConnected >> 30) & 0x3u).ShouldBe(2u);
((StatusCodeMap.BadOutOfService >> 30) & 0x3u).ShouldBe(2u);
}
}

View File

@@ -0,0 +1,137 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// <summary>
/// Tests for <see cref="SubscriptionRegistry"/> — the bookkeeping the EventPump
/// uses to fan one OnDataChange event out to every driver subscription that
/// observes the changed item handle.
/// </summary>
public sealed class SubscriptionRegistryTests
{
[Fact]
public void NextSubscriptionId_IsMonotonic()
{
var registry = new SubscriptionRegistryAccess();
registry.NextSubscriptionId().ShouldBe(1);
registry.NextSubscriptionId().ShouldBe(2);
registry.NextSubscriptionId().ShouldBe(3);
}
[Fact]
public void Register_OneSubscription_OneTag_ResolvesSingleSubscriber()
{
var registry = new SubscriptionRegistryAccess();
registry.Register(42, [new TagBindingAccess("Tank.Level", 100)]);
var subs = registry.ResolveSubscribers(100);
subs.Count.ShouldBe(1);
subs[0].SubscriptionId.ShouldBe(42);
subs[0].FullReference.ShouldBe("Tank.Level");
}
[Fact]
public void Register_TwoSubscriptions_SameTag_FanOutToBoth()
{
var registry = new SubscriptionRegistryAccess();
registry.Register(1, [new TagBindingAccess("Tank.Level", 100)]);
registry.Register(2, [new TagBindingAccess("Tank.Level", 100)]);
var subs = registry.ResolveSubscribers(100);
subs.Count.ShouldBe(2);
subs.Select(s => s.SubscriptionId).OrderBy(x => x).ShouldBe(new[] { 1L, 2L });
}
[Fact]
public void Register_FailedItemHandle_NotIndexedForFanOut()
{
var registry = new SubscriptionRegistryAccess();
registry.Register(1, [
new TagBindingAccess("Good", 100),
new TagBindingAccess("Bad", 0), // gw rejected this tag
]);
registry.ResolveSubscribers(100).Count.ShouldBe(1);
registry.ResolveSubscribers(0).ShouldBeEmpty();
}
[Fact]
public void Remove_DropsAllBindings_AndReturnsThemForUnsubscribe()
{
var registry = new SubscriptionRegistryAccess();
registry.Register(1, [
new TagBindingAccess("A", 100),
new TagBindingAccess("B", 200),
]);
var removed = registry.Remove(1);
removed.ShouldNotBeNull();
removed!.Count.ShouldBe(2);
registry.ResolveSubscribers(100).ShouldBeEmpty();
registry.ResolveSubscribers(200).ShouldBeEmpty();
}
[Fact]
public void Remove_OneOfTwoSubscriptions_LeavesOtherIntact()
{
var registry = new SubscriptionRegistryAccess();
registry.Register(1, [new TagBindingAccess("A", 100)]);
registry.Register(2, [new TagBindingAccess("A", 100)]);
registry.Remove(1);
var subs = registry.ResolveSubscribers(100);
subs.Count.ShouldBe(1);
subs[0].SubscriptionId.ShouldBe(2);
}
[Fact]
public void Remove_UnknownSubscription_IsNullSentinel()
{
var registry = new SubscriptionRegistryAccess();
registry.Remove(999).ShouldBeNull();
}
[Fact]
public void TrackedCounts_ReflectAdditionsAndRemovals()
{
var registry = new SubscriptionRegistryAccess();
registry.TrackedSubscriptionCount.ShouldBe(0);
registry.Register(1, [new TagBindingAccess("A", 100)]);
registry.Register(2, [new TagBindingAccess("A", 100), new TagBindingAccess("B", 200)]);
registry.TrackedSubscriptionCount.ShouldBe(2);
registry.TrackedItemHandleCount.ShouldBe(2);
registry.Remove(1);
registry.TrackedSubscriptionCount.ShouldBe(1);
registry.TrackedItemHandleCount.ShouldBe(2); // sub 2 still observes both handles
registry.Remove(2);
registry.TrackedSubscriptionCount.ShouldBe(0);
registry.TrackedItemHandleCount.ShouldBe(0);
}
// Internal types are accessed via friend assembly (InternalsVisibleTo); these
// wrapper aliases keep the test code readable.
private sealed class SubscriptionRegistryAccess
{
private readonly SubscriptionRegistry _inner = new();
public int TrackedSubscriptionCount => _inner.TrackedSubscriptionCount;
public int TrackedItemHandleCount => _inner.TrackedItemHandleCount;
public long NextSubscriptionId() => _inner.NextSubscriptionId();
public void Register(long id, IReadOnlyList<TagBindingAccess> bindings)
=> _inner.Register(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]);
public IReadOnlyList<TagBindingAccess>? Remove(long id)
{
var removed = _inner.Remove(id);
return removed is null ? null : [.. removed.Select(b => new TagBindingAccess(b.FullReference, b.ItemHandle))];
}
public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int handle)
=> _inner.ResolveSubscribers(handle);
}
private sealed record TagBindingAccess(string FullReference, int ItemHandle);
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
<!-- Pulled in transitively via Driver.Galaxy → MxGateway.Client → MxGateway.Contracts;
explicit reference lets tests construct GalaxyObject / GalaxyAttribute fixtures. -->
<ProjectReference Include="..\..\..\..\mxaccessgw\src\MxGateway.Contracts\MxGateway.Contracts.csproj"/>
</ItemGroup>
</Project>