chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.1–4.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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user