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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user