docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
This commit is contained in:
Joseph Doherty
2026-05-28 08:10:17 -04:00
parent f9fc7dd2e1
commit 64e3fbe035
756 changed files with 9876 additions and 96 deletions
@@ -15,6 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;
/// </summary>
public sealed class DataTypeMapTests
{
/// <summary>Verifies that Map maps known codes to expected driver data types.</summary>
/// <param name="mxDataType">The Galaxy mx_data_type integer code.</param>
/// <param name="expected">The expected driver data type.</param>
[Theory]
[InlineData(0, DriverDataType.Boolean)]
[InlineData(1, DriverDataType.Int32)]
@@ -28,6 +31,7 @@ public sealed class DataTypeMapTests
DataTypeMap.Map(mxDataType).ShouldBe(expected);
}
/// <summary>Verifies that Int64 code does not fall through to String default.</summary>
[Fact]
public void Map_Int64Code_DoesNotFallThroughToStringDefault()
{
@@ -36,6 +40,8 @@ public sealed class DataTypeMapTests
DataTypeMap.Map(6).ShouldNotBe(DriverDataType.String);
}
/// <summary>Verifies that unknown code falls back to String.</summary>
/// <param name="mxDataType">An unrecognised Galaxy mx_data_type code.</param>
[Theory]
[InlineData(7)]
[InlineData(99)]
@@ -23,20 +23,46 @@ public sealed class DeployWatcherTests
private sealed class FakeDeployWatchSource : IGalaxyDeployWatchSource
{
private readonly Func<int, Channel<DeployEvent>> _channelFactory;
/// <summary>
/// Gets the list of last-seen deployment times from each watch iteration.
/// </summary>
public List<DateTimeOffset?> LastSeenTimes { get; } = [];
/// <summary>
/// Gets the number of times WatchAsync has been called.
/// </summary>
public int CallCount { get; private set; }
/// <summary>
/// Gets or sets a function that can throw an exception on specific iterations.
/// </summary>
public Func<int, Exception?>? ThrowOnIteration { get; init; }
/// <summary>
/// Initializes a new instance of the <see cref="FakeDeployWatchSource"/> class with a single channel.
/// </summary>
/// <param name="channel">The deploy event channel to use for all iterations.</param>
public FakeDeployWatchSource(Channel<DeployEvent> channel)
{
_channelFactory = _ => channel;
}
/// <summary>
/// Initializes a new instance of the <see cref="FakeDeployWatchSource"/> class with a channel factory.
/// </summary>
/// <param name="channelFactory">A factory function that creates channels for each iteration.</param>
public FakeDeployWatchSource(Func<int, Channel<DeployEvent>> channelFactory)
{
_channelFactory = channelFactory;
}
/// <summary>
/// Watches for deploy events asynchronously.
/// </summary>
/// <param name="lastSeenDeployTime">The last deployment time seen.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async enumerable of deploy events.</returns>
public async IAsyncEnumerable<DeployEvent> WatchAsync(
DateTimeOffset? lastSeenDeployTime,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -93,6 +119,7 @@ public sealed class DeployWatcherTests
throw new TimeoutException("Condition was not met within timeout.");
}
/// <summary>Verifies that bootstrap deploy events are suppressed.</summary>
[Fact]
public async Task BootstrapEventIsSuppressed()
{
@@ -115,6 +142,7 @@ public sealed class DeployWatcherTests
await watcher.StopAsync();
}
/// <summary>Verifies that a deployment time change fires a rediscovery event.</summary>
[Fact]
public async Task DeployTimeChangeFiresRediscover()
{
@@ -142,6 +170,7 @@ public sealed class DeployWatcherTests
await watcher.StopAsync();
}
/// <summary>Verifies that the same deployment time does not fire a rediscovery event.</summary>
[Fact]
public async Task SameDeployTimeDoesNotFire()
{
@@ -165,6 +194,7 @@ public sealed class DeployWatcherTests
await watcher.StopAsync();
}
/// <summary>Verifies that a deployment time presence flip fires a rediscovery event.</summary>
[Fact]
public async Task TimeOfLastDeployPresentFlipFiresRediscover()
{
@@ -191,6 +221,7 @@ public sealed class DeployWatcherTests
await watcher.StopAsync();
}
/// <summary>Verifies that stop cancels the watcher loop cleanly.</summary>
[Fact]
public async Task StopCancelsLoopCleanly()
{
@@ -211,6 +242,7 @@ public sealed class DeployWatcherTests
await stopTask; // observe (no) exception
}
/// <summary>Verifies that disposing stops a running watcher.</summary>
[Fact]
public async Task DisposeStopsRunningWatcher()
{
@@ -229,6 +261,7 @@ public sealed class DeployWatcherTests
await disposeTask;
}
/// <summary>Verifies that a source exception triggers retry with backoff.</summary>
[Fact]
public async Task SourceExceptionTriggersRetryWithBackoff()
{
@@ -16,6 +16,9 @@ public sealed class GalaxyDiscovererTests
{
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
{
/// <summary>Gets the hierarchy asynchronously from the fake source.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns the pre-built Galaxy object list.</returns>
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
=> Task.FromResult(objects);
}
@@ -25,15 +28,23 @@ public sealed class GalaxyDiscovererTests
private sealed class FakeBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of folder creation calls recorded by this builder.</summary>
public List<FolderCall> Folders { get; } = [];
/// <summary>Gets the list of variable creation calls recorded by this builder.</summary>
public List<VariableCall> Variables { get; } = [];
/// <summary>Gets the dictionary of alarm declarations recorded by this builder.</summary>
public Dictionary<string, AlarmConditionInfo> AlarmDeclarations { get; } = [];
private readonly string? _currentFolder;
/// <summary>Initializes a new instance of the FakeBuilder class at the root level.</summary>
public FakeBuilder() : this(null) { }
private FakeBuilder(string? folder) { _currentFolder = folder; }
/// <summary>Adds a folder call to the recorded list.</summary>
/// <param name="browseName">The browse name for the folder.</param>
/// <param name="displayName">The display name for the folder.</param>
/// <returns>An IAddressSpaceBuilder scoped to the new folder.</returns>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add(new FolderCall(browseName, displayName));
@@ -41,6 +52,11 @@ public sealed class GalaxyDiscovererTests
return new ChildBuilder(this, browseName);
}
/// <summary>Adds a variable call to the recorded list.</summary>
/// <param name="browseName">The browse name for the variable.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="attributeInfo">The attribute metadata for the variable.</param>
/// <returns>An IVariableHandle for further configuration.</returns>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
var folder = _currentFolder ?? "<root>";
@@ -48,30 +64,51 @@ public sealed class GalaxyDiscovererTests
return new FakeVariableHandle(this, attributeInfo.FullName);
}
/// <summary>Adds a property call to the builder (not recorded in this fake).</summary>
/// <param name="browseName">The browse name for the property.</param>
/// <param name="dataType">The driver data type of the property.</param>
/// <param name="value">The property value.</param>
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
// Child folder routes Variable calls back to the parent's lists with its own scope.
/// <summary>Child folder routes Variable calls back to the parent's lists with its own scope.</summary>
private sealed class ChildBuilder(FakeBuilder parent, string folderBrowseName) : IAddressSpaceBuilder
{
/// <summary>Adds a child folder call to the parent builder's recorded list.</summary>
/// <param name="browseName">The browse name for the folder.</param>
/// <param name="displayName">The display name for the folder.</param>
/// <returns>An IAddressSpaceBuilder scoped to the new child folder.</returns>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
parent.Folders.Add(new FolderCall(browseName, displayName));
return new ChildBuilder(parent, browseName);
}
/// <summary>Adds a variable call to the parent builder's recorded list, scoped to this folder.</summary>
/// <param name="browseName">The browse name for the variable.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="attributeInfo">The attribute metadata for the variable.</param>
/// <returns>An IVariableHandle for further configuration.</returns>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
parent.Variables.Add(new VariableCall(folderBrowseName, browseName, attributeInfo));
return new FakeVariableHandle(parent, attributeInfo.FullName);
}
/// <summary>Adds a property call to the builder (not recorded in this fake).</summary>
/// <param name="browseName">The browse name for the property.</param>
/// <param name="dataType">The driver data type of the property.</param>
/// <param name="value">The property value.</param>
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
}
private sealed class FakeVariableHandle(FakeBuilder owner, string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference for this variable.</summary>
public string FullReference { get; } = fullRef;
/// <summary>Marks this variable as an alarm condition and records it.</summary>
/// <param name="info">The alarm condition metadata.</param>
/// <returns>An IAlarmConditionSink for further alarm configuration.</returns>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
owner.AlarmDeclarations[FullReference] = info;
@@ -81,6 +118,8 @@ public sealed class GalaxyDiscovererTests
private sealed class NoopSink : IAlarmConditionSink
{
/// <summary>Records an alarm transition event (no-op in this fake).</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
@@ -116,6 +155,7 @@ public sealed class GalaxyDiscovererTests
return o;
}
/// <summary>Verifies that discovery creates one folder per object and one variable per attribute.</summary>
[Fact]
public async Task DiscoverAsync_BuildsOneFolderPerObject_AndOneVariablePerAttribute()
{
@@ -137,6 +177,7 @@ public sealed class GalaxyDiscovererTests
builder.Variables.ShouldContain(v => v.FolderBrowseName == "Pump" && v.AttributeName == "Running");
}
/// <summary>Verifies that full reference defaults to tag.attribute format when not explicitly supplied.</summary>
[Fact]
public async Task DiscoverAsync_FullReference_DefaultsToTagDotAttribute()
{
@@ -151,6 +192,7 @@ public sealed class GalaxyDiscovererTests
builder.Variables[0].Info.FullName.ShouldBe("Tank1_Level.PV");
}
/// <summary>Verifies that full reference uses gateway-supplied value when provided.</summary>
[Fact]
public async Task DiscoverAsync_FullReference_PrefersGwSuppliedFullTagReference()
{
@@ -165,6 +207,7 @@ public sealed class GalaxyDiscovererTests
builder.Variables[0].Info.FullName.ShouldBe("explicit.full.ref");
}
/// <summary>Verifies that browse name falls back to tag name when contained name is empty.</summary>
[Fact]
public async Task DiscoverAsync_BrowseName_FallsBackToTagName_WhenContainedEmpty()
{
@@ -179,6 +222,7 @@ public sealed class GalaxyDiscovererTests
builder.Folders[0].BrowseName.ShouldBe("Tank1_Level");
}
/// <summary>Verifies that attribute metadata fields are all propagated to the discovered variable.</summary>
[Fact]
public async Task DiscoverAsync_AttributeMetadata_PropagatesEveryField()
{
@@ -203,6 +247,7 @@ public sealed class GalaxyDiscovererTests
info.IsAlarm.ShouldBeFalse();
}
/// <summary>Verifies that alarm attributes populate all five sub-attribute references.</summary>
[Fact]
public async Task DiscoverAsync_AlarmAttribute_PopulatesAllFiveSubAttributeRefs()
{
@@ -224,6 +269,7 @@ public sealed class GalaxyDiscovererTests
info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg");
}
/// <summary>Verifies that non-alarm attributes are not marked as alarm conditions.</summary>
[Fact]
public async Task DiscoverAsync_NonAlarmAttribute_DoesNotMarkCondition()
{
@@ -240,6 +286,7 @@ public sealed class GalaxyDiscovererTests
builder.AlarmDeclarations.ShouldNotContainKey("T.PV");
}
/// <summary>Verifies that objects with empty identity are skipped during discovery.</summary>
[Fact]
public async Task DiscoverAsync_SkipsObjectsWithEmptyIdentity()
{
@@ -256,6 +303,7 @@ public sealed class GalaxyDiscovererTests
builder.Folders[0].BrowseName.ShouldBe("Real");
}
/// <summary>Verifies that attributes with empty names are skipped during discovery.</summary>
[Fact]
public async Task DiscoverAsync_SkipsAttributesWithEmptyName()
{
@@ -271,6 +319,7 @@ public sealed class GalaxyDiscovererTests
builder.Variables[0].AttributeName.ShouldBe("PV");
}
/// <summary>Verifies that driver discovery routes through the injected hierarchy source.</summary>
[Fact]
public async Task DriverDiscoverAsync_RoutesThroughInjectedSource()
{
@@ -287,6 +336,9 @@ public sealed class GalaxyDiscovererTests
/// <summary>Helper that exercises the internal ctor (test seam) without exposing it publicly.</summary>
private sealed class GalaxyDriverHelper
{
/// <summary>Creates a GalaxyDriver with a fake hierarchy source for testing.</summary>
/// <param name="source">The fake hierarchy source to inject.</param>
/// <returns>A GalaxyDriver configured with the fake source.</returns>
public GalaxyDriver CreateWithFakeSource(IGalaxyHierarchySource source)
=> new GalaxyDriver(
"galaxy-test",
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// </summary>
public sealed class GalaxyDriverAlarmEventArgsExtensionTests
{
/// <summary>
/// Verifies that acknowledge transition with full payload populates extended fields.
/// </summary>
[Fact]
public async Task Acknowledge_transition_with_full_payload_populates_extended_fields()
{
@@ -47,6 +50,9 @@ public sealed class GalaxyDriverAlarmEventArgsExtensionTests
observed[0].AlarmCategory.ShouldBe("Process");
}
/// <summary>
/// Verifies that raise transition without optional fields leaves them null.
/// </summary>
[Fact]
public async Task Raise_transition_without_optional_fields_leaves_them_null()
{
@@ -95,13 +101,26 @@ public sealed class GalaxyDriverAlarmEventArgsExtensionTests
/// <summary>In-memory <see cref="IGalaxyAlarmFeed"/> the test drives directly.</summary>
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
{
/// <summary>
/// Occurs when an alarm transition is raised.
/// </summary>
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
/// <summary>
/// Starts the alarm feed.
/// </summary>
public void Start() { }
/// <summary>
/// Emits an alarm transition to subscribers.
/// </summary>
/// <param name="transition">The alarm transition to emit.</param>
public void Emit(GalaxyAlarmTransition transition)
=> OnAlarmTransition?.Invoke(this, transition);
/// <summary>
/// Disposes the alarm feed asynchronously.
/// </summary>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// </summary>
public sealed class GalaxyDriverAlarmSourceTests
{
/// <summary>Verifies that SubscribeAlarmsAsync starts the alarm feed and events fire on transition.</summary>
[Fact]
public async Task SubscribeAlarmsAsync_starts_feed_and_event_fires_on_transition()
{
@@ -41,6 +42,7 @@ public sealed class GalaxyDriverAlarmSourceTests
observed[0].SubscriptionHandle.ShouldBe(handle);
}
/// <summary>Verifies that OnAlarmEvent does not fire before any alarm subscription.</summary>
[Fact]
public void OnAlarmEvent_does_not_fire_before_any_alarm_subscription()
{
@@ -59,6 +61,7 @@ public sealed class GalaxyDriverAlarmSourceTests
observed.ShouldBeEmpty();
}
/// <summary>Verifies that UnsubscribeAlarmsAsync stops event flow.</summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_stops_event_flow()
{
@@ -80,6 +83,7 @@ public sealed class GalaxyDriverAlarmSourceTests
observed.ShouldBeEmpty();
}
/// <summary>Verifies that UnsubscribeAlarmsAsync throws for a foreign handle.</summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_throws_for_foreign_handle()
{
@@ -92,6 +96,7 @@ public sealed class GalaxyDriverAlarmSourceTests
driver.UnsubscribeAlarmsAsync(foreignHandle, CancellationToken.None));
}
/// <summary>Verifies that AcknowledgeAsync routes each request to the acknowledger.</summary>
[Fact]
public async Task AcknowledgeAsync_routes_each_request_to_the_acknowledger()
{
@@ -113,6 +118,7 @@ public sealed class GalaxyDriverAlarmSourceTests
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
}
/// <summary>Verifies that AcknowledgeAsync falls back to SourceNodeId when ConditionId is empty.</summary>
[Fact]
public async Task AcknowledgeAsync_falls_back_to_SourceNodeId_when_ConditionId_empty()
{
@@ -127,6 +133,7 @@ public sealed class GalaxyDriverAlarmSourceTests
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
}
/// <summary>Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.</summary>
[Fact]
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
{
@@ -178,22 +185,37 @@ public sealed class GalaxyDriverAlarmSourceTests
/// <summary>In-memory <see cref="IGalaxyAlarmFeed"/> the test drives directly.</summary>
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
{
/// <summary>Gets a value indicating whether the feed has been started.</summary>
public bool Started { get; private set; }
/// <summary>Occurs when an alarm transition is emitted.</summary>
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
/// <summary>Marks the feed as started.</summary>
public void Start() => Started = true;
/// <summary>Emits an alarm transition to all subscribers.</summary>
/// <param name="transition">The transition to emit.</param>
public void Emit(GalaxyAlarmTransition transition)
=> OnAlarmTransition?.Invoke(this, transition);
/// <summary>Disposes the fake feed.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Test double that records all acknowledge calls.</summary>
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
{
/// <summary>Gets the list of acknowledge calls recorded.</summary>
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
/// <summary>Records an acknowledge call.</summary>
/// <param name="alarmFullReference">The alarm full reference.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorUser">The operator user.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A completed task.</returns>
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
{
Calls.Add((alarmFullReference, comment, operatorUser));
@@ -201,8 +223,10 @@ public sealed class GalaxyDriverAlarmSourceTests
}
}
/// <summary>Test double that represents a foreign alarm subscription handle.</summary>
private sealed class ForeignAlarmHandle : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic ID for this handle.</summary>
public string DiagnosticId => "foreign";
}
}
@@ -12,12 +12,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// </summary>
public sealed class GalaxyDriverApiKeyResolverTests
{
/// <summary>Verifies that a literal string is returned unchanged.</summary>
[Fact]
public void Literal_string_is_returned_unchanged()
{
GalaxyDriver.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
}
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
[Fact]
public void Env_prefix_resolves_to_environment_variable()
{
@@ -33,6 +35,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
}
}
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
[Fact]
public void Env_prefix_unset_variable_throws_with_descriptive_message()
{
@@ -45,6 +48,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
ex.Message.ShouldContain("unset");
}
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
[Fact]
public void File_prefix_resolves_to_trimmed_file_contents()
{
@@ -60,6 +64,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
}
}
/// <summary>Verifies that file: prefix with missing path throws.</summary>
[Fact]
public void File_prefix_missing_path_throws()
{
@@ -72,6 +77,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
[Fact]
public void Literal_string_emits_warning_when_logger_supplied()
{
@@ -86,6 +92,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
e.Level == LogLevel.Warning && e.Message.Contains("literal", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
[Fact]
public void Dev_prefix_returns_literal_without_warning()
{
@@ -99,6 +106,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
[Fact]
public void Env_prefix_does_not_emit_literal_warning()
{
@@ -116,15 +124,24 @@ public sealed class GalaxyDriverApiKeyResolverTests
}
}
/// <summary>A test logger that captures log entries for verification.</summary>
private sealed class CaptureLogger : ILogger
{
/// <summary>Gets the list of captured log entries with their levels and messages.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = new();
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
}
/// <summary>Verifies that file: prefix with empty file throws.</summary>
[Fact]
public void File_prefix_empty_file_throws()
{
@@ -23,6 +23,7 @@ public sealed class GalaxyDriverFactoryTests
}
""";
/// <summary>Verifies that minimal config is parsed and defaults are applied.</summary>
[Fact]
public void CreateInstance_ParsesMinimalConfig_AndAppliesDefaults()
{
@@ -40,6 +41,7 @@ public sealed class GalaxyDriverFactoryTests
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue();
}
/// <summary>Verifies that defaults are overridden from full config.</summary>
[Fact]
public void CreateInstance_OverridesDefaults_FromFullConfig()
{
@@ -77,6 +79,7 @@ public sealed class GalaxyDriverFactoryTests
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeFalse();
}
/// <summary>Verifies that missing endpoint throws an exception.</summary>
[Fact]
public void CreateInstance_MissingEndpoint_Throws()
{
@@ -85,6 +88,7 @@ public sealed class GalaxyDriverFactoryTests
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("Gateway.Endpoint");
}
/// <summary>Verifies that missing API key throws an exception.</summary>
[Fact]
public void CreateInstance_MissingApiKey_Throws()
{
@@ -93,6 +97,7 @@ public sealed class GalaxyDriverFactoryTests
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("ApiKeySecretRef");
}
/// <summary>Verifies that missing client name throws an exception.</summary>
[Fact]
public void CreateInstance_MissingClientName_Throws()
{
@@ -101,6 +106,7 @@ public sealed class GalaxyDriverFactoryTests
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("MxAccess.ClientName");
}
/// <summary>Verifies that the factory is registered in the driver factory registry.</summary>
[Fact]
public void Register_AddsFactoryToRegistry()
{
@@ -118,6 +124,7 @@ public sealed class GalaxyDriverFactoryTests
driver.DriverType.ShouldBe(GalaxyDriverFactoryExtensions.DriverTypeName);
}
/// <summary>Verifies that driver lifecycle toggles health state on initialize and shutdown.</summary>
[Fact]
public async Task DriverLifecycle_InitializeShutdown_ToggleHealth()
{
@@ -140,6 +147,7 @@ public sealed class GalaxyDriverFactoryTests
await driver.FlushOptionalCachesAsync(CancellationToken.None); // no-op shouldn't throw
}
/// <summary>Verifies that reinitializing with equivalent config refreshes health.</summary>
[Fact]
public async Task ReinitializeAsync_RefreshesHealth_WhenConfigIsEquivalent()
{
@@ -182,13 +190,16 @@ public sealed class GalaxyDriverFactoryTests
/// </summary>
private sealed class NoopSubscriber : IGalaxySubscriber
{
/// <inheritdoc />
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
/// <inheritdoc />
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -197,6 +208,7 @@ public sealed class GalaxyDriverFactoryTests
}
}
/// <summary>Verifies that dispose is idempotent and shutdown after dispose is harmless.</summary>
[Fact]
public void Dispose_IsIdempotent_AndShutdownAfterDisposeIsHarmless()
{
@@ -205,6 +217,7 @@ public sealed class GalaxyDriverFactoryTests
Should.NotThrow(() => driver.Dispose());
}
/// <summary>Verifies that initializing after dispose throws an exception.</summary>
[Fact]
public async Task InitializeAfterDispose_Throws()
{
@@ -214,6 +227,7 @@ public sealed class GalaxyDriverFactoryTests
driver.InitializeAsync(MinimalConfig, CancellationToken.None));
}
/// <summary>Verifies that the driver implements all Phase 4 capabilities.</summary>
[Fact]
public void DriverImplementsAllPhase4Capabilities()
{
@@ -229,6 +243,7 @@ public sealed class GalaxyDriverFactoryTests
driver.ShouldBeAssignableTo<IHostConnectivityProbe>();
}
/// <summary>Verifies that GetHostStatuses returns an empty snapshot after initialization with seam.</summary>
[Fact]
public async Task GetHostStatuses_AfterInitWithSeam_ReturnsEmptySnapshot()
{
@@ -243,6 +258,7 @@ public sealed class GalaxyDriverFactoryTests
driver.GetHostStatuses().ShouldBeEmpty();
}
/// <summary>Verifies that the driver type is GalaxyMxGateway, not the legacy Galaxy type.</summary>
[Fact]
public void DriverType_IsGalaxyMxGateway_NotLegacyGalaxy()
{
@@ -23,6 +23,7 @@ public sealed class GalaxyDriverInfrastructureTests
// ===== Driver.Galaxy-011 regression: GetMemoryFootprint reflects registry size =====
/// <summary>Verifies that memory footprint is zero when there are no subscriptions.</summary>
[Fact]
public void GetMemoryFootprint_IsZeroWhenNoSubscriptions()
{
@@ -32,6 +33,7 @@ public sealed class GalaxyDriverInfrastructureTests
driver.GetMemoryFootprint().ShouldBe(0L);
}
/// <summary>Verifies that memory footprint is nonzero after subscriptions are active.</summary>
[Fact]
public async Task GetMemoryFootprint_IsNonZeroAfterSubscribe()
{
@@ -45,6 +47,7 @@ public sealed class GalaxyDriverInfrastructureTests
driver.GetMemoryFootprint().ShouldBeGreaterThan(0L);
}
/// <summary>Verifies that memory footprint decreases after unsubscribing.</summary>
[Fact]
public async Task GetMemoryFootprint_DecreasesAfterUnsubscribe()
{
@@ -63,6 +66,7 @@ public sealed class GalaxyDriverInfrastructureTests
// ===== Driver.Galaxy-007 regression: Dispose cancels the dispose CTS =====
/// <summary>Verifies that Dispose sets disposed flag and blocks further capability calls.</summary>
[Fact]
public async Task Dispose_SetsDisposedFlag_BlockingFurtherCapabilityCalls()
{
@@ -76,6 +80,7 @@ public sealed class GalaxyDriverInfrastructureTests
driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None));
}
/// <summary>Verifies that DisposeAsync can be awaited without deadlock.</summary>
[Fact]
public async Task DisposeAsync_CanBeAwaitedWithoutDeadlock()
{
@@ -87,6 +92,7 @@ public sealed class GalaxyDriverInfrastructureTests
// ===== Driver.Galaxy-013 regression: ReplayOnSessionLost gates the replay step =====
/// <summary>Verifies that ReplayOnSessionLost=false skips resubscription on reconnect.</summary>
[Fact]
public async Task ReplayOnSessionLost_False_SkipsResubscribeBulk()
{
@@ -116,6 +122,7 @@ public sealed class GalaxyDriverInfrastructureTests
"ReplayOnSessionLost=false must skip the re-SubscribeBulk fan-out on reconnect");
}
/// <summary>Verifies that ReplayOnSessionLost=true runs resubscription on reconnect.</summary>
[Fact]
public async Task ReplayOnSessionLost_True_RunsResubscribeBulk()
{
@@ -137,12 +144,18 @@ public sealed class GalaxyDriverInfrastructureTests
"default ReplayOnSessionLost=true must re-issue SubscribeBulk after a transport drop");
}
/// <summary>Tracks subscription calls for replay testing.</summary>
private sealed class ReplayCountingSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
private int _nextHandle = 1;
/// <summary>Gets the count of subscription calls.</summary>
public int SubscribeCalls;
/// <summary>Subscribes to multiple tags and counts the call.</summary>
/// <param name="fullReferences">List of tag addresses to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -156,15 +169,21 @@ public sealed class GalaxyDriverInfrastructureTests
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Unsubscribes from multiple tags.</summary>
/// <param name="itemHandles">List of subscription handles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams subscription events.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
}
// ===== Driver.Galaxy-013 regression: ReinitializeAsync rejects unsupported reapply =====
/// <summary>Verifies that ReinitializeAsync rejects non-equivalent config changes.</summary>
[Fact]
public async Task ReinitializeAsync_RejectsNonEquivalentConfigChange()
{
@@ -183,6 +202,7 @@ public sealed class GalaxyDriverInfrastructureTests
await driver.ReinitializeAsync(newConfig, CancellationToken.None));
}
/// <summary>Verifies that ReinitializeAsync accepts equivalent config.</summary>
[Fact]
public async Task ReinitializeAsync_AcceptsEquivalentConfig()
{
@@ -202,10 +222,15 @@ public sealed class GalaxyDriverInfrastructureTests
// ===== Minimal IGalaxySubscriber fake that returns empty results for subscribe calls =====
/// <summary>No-op subscriber that returns empty results for testing.</summary>
private sealed class NoOpSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
/// <summary>Subscribes to multiple tags (no-op).</summary>
/// <param name="fullReferences">List of tag addresses to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -218,9 +243,14 @@ public sealed class GalaxyDriverInfrastructureTests
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Unsubscribes from multiple tags (no-op).</summary>
/// <param name="itemHandles">List of subscription handles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams subscription events (no-op).</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
}
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Health;
/// </summary>
public sealed class HostConnectivityForwarderTests
{
/// <summary>Verifies that SetTransport pushes the state change under the client name.</summary>
[Fact]
public void SetTransport_Running_PushesUnderClientName()
{
@@ -30,6 +31,7 @@ public sealed class HostConnectivityForwarderTests
agg.Snapshot()[0].HostName.ShouldBe("OtOpcUa-A");
}
/// <summary>Verifies that SetTransport fires a change event on state transitions.</summary>
[Fact]
public void SetTransport_StateTransition_FiresChange()
{
@@ -47,6 +49,7 @@ public sealed class HostConnectivityForwarderTests
captured[0].NewState.ShouldBe(HostState.Stopped);
}
/// <summary>Verifies that repeated identical state changes do not fire events.</summary>
[Fact]
public void SetTransport_RepeatedSameState_DoesNotFire()
{
@@ -64,6 +67,7 @@ public sealed class HostConnectivityForwarderTests
captured.ShouldBeEmpty();
}
/// <summary>Verifies that the constructor rejects empty client names.</summary>
[Fact]
public void Constructor_RejectsEmptyClientName()
{
@@ -72,6 +76,7 @@ public sealed class HostConnectivityForwarderTests
Should.Throw<ArgumentException>(() => new HostConnectivityForwarder(" ", agg));
}
/// <summary>Verifies that SetTransport throws after the forwarder is disposed.</summary>
[Fact]
public void SetTransport_AfterDispose_Throws()
{
@@ -15,6 +15,7 @@ public sealed class HostStatusAggregatorTests
private static HostConnectivityStatus Status(string host, HostState state) =>
new(host, state, DateTime.UtcNow);
/// <summary>Verifies that snapshot is empty when nothing is tracked.</summary>
[Fact]
public void Snapshot_Empty_WhenNothingTracked()
{
@@ -22,6 +23,7 @@ public sealed class HostStatusAggregatorTests
agg.Snapshot().ShouldBeEmpty();
}
/// <summary>Verifies that updating a new host fires a change event with Unknown as previous state.</summary>
[Fact]
public void Update_NewHost_FiresChange_PreviousIsUnknown()
{
@@ -37,6 +39,7 @@ public sealed class HostStatusAggregatorTests
captured[0].NewState.ShouldBe(HostState.Running);
}
/// <summary>Verifies that updating to the same state does not fire a change event.</summary>
[Fact]
public void Update_SameState_DoesNotFire()
{
@@ -51,6 +54,7 @@ public sealed class HostStatusAggregatorTests
captured.ShouldBeEmpty();
}
/// <summary>Verifies that state transitions fire change events with correct old and new states.</summary>
[Fact]
public void Update_StateTransition_FiresChangeWithCorrectPreviousAndNew()
{
@@ -67,6 +71,7 @@ public sealed class HostStatusAggregatorTests
captured[0].NewState.ShouldBe(HostState.Stopped);
}
/// <summary>Verifies that snapshot reflects every upserted host.</summary>
[Fact]
public void Snapshot_ReflectsEveryUpsertedHost()
{
@@ -82,6 +87,7 @@ public sealed class HostStatusAggregatorTests
snap.First(s => s.HostName == "PlatformB").State.ShouldBe(HostState.Stopped);
}
/// <summary>Verifies that host name comparison is case-insensitive.</summary>
[Fact]
public void Update_HostNameComparison_IsCaseInsensitive()
{
@@ -98,6 +104,7 @@ public sealed class HostStatusAggregatorTests
agg.Snapshot().Count.ShouldBe(1);
}
/// <summary>Verifies that removing a tracked host returns true and drops it from snapshot.</summary>
[Fact]
public void Remove_TrackedHost_ReturnsTrue_AndDropsFromSnapshot()
{
@@ -107,6 +114,7 @@ public sealed class HostStatusAggregatorTests
agg.Snapshot().ShouldBeEmpty();
}
/// <summary>Verifies that removing an unknown host returns false.</summary>
[Fact]
public void Remove_UnknownHost_ReturnsFalse()
{
@@ -114,6 +122,7 @@ public sealed class HostStatusAggregatorTests
agg.Remove("Nope").ShouldBeFalse();
}
/// <summary>Verifies that concurrent updates do not corrupt the internal dictionary.</summary>
[Fact]
public void ConcurrentUpdates_DoNotCorruptDictionary()
{
@@ -17,12 +17,20 @@ public sealed class PerPlatformProbeWatcherTests
{
private sealed class FakeSubscriber : IGalaxySubscriber
{
/// <summary>Gets a list of all subscribe requests made to the subscriber.</summary>
public List<List<string>> Subscribes { get; } = [];
/// <summary>Gets the buffered update intervals used in each subscribe request.</summary>
public List<int> SubscribeIntervalsMs { get; } = [];
/// <summary>Gets a list of all unsubscribe requests made to the subscriber.</summary>
public List<List<int>> Unsubscribes { get; } = [];
private int _nextHandle = 1;
/// <summary>Gets a mapping of tag addresses to their assigned item handles.</summary>
public Dictionary<string, int> HandleByAddress { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Simulates a bulk subscribe operation by generating handles for each reference.</summary>
/// <param name="fullReferences">The list of tag addresses to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -43,12 +51,17 @@ public sealed class PerPlatformProbeWatcherTests
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Simulates a bulk unsubscribe operation by recording the handles.</summary>
/// <param name="itemHandles">The list of item handles to unsubscribe.</param>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
Unsubscribes.Add([.. itemHandles]);
return Task.CompletedTask;
}
/// <summary>Returns an empty event stream for testing.</summary>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> Empty();
@@ -59,6 +72,7 @@ public sealed class PerPlatformProbeWatcherTests
}
}
/// <summary>Verifies that syncing platforms subscribes to the ScanState address for each platform.</summary>
[Fact]
public async Task SyncPlatformsAsync_SubscribesScanStateAddressForEachPlatform()
{
@@ -73,6 +87,7 @@ public sealed class PerPlatformProbeWatcherTests
watcher.WatchedPlatforms.OrderBy(x => x).ShouldBe(new[] { "PlatformA", "PlatformB" });
}
/// <summary>Verifies that the default buffered interval is zero, matching gateway cadence.</summary>
[Fact]
public async Task SyncPlatformsAsync_DefaultBufferedIntervalIsZero_GwCadence()
{
@@ -84,6 +99,7 @@ public sealed class PerPlatformProbeWatcherTests
subscriber.SubscribeIntervalsMs.ShouldHaveSingleItem().ShouldBe(0);
}
/// <summary>Verifies that a configured buffered interval is forwarded to the gateway.</summary>
[Fact]
public async Task SyncPlatformsAsync_ConfiguredBufferedInterval_IsForwardedToGw()
{
@@ -99,6 +115,7 @@ public sealed class PerPlatformProbeWatcherTests
subscriber.SubscribeIntervalsMs.ShouldHaveSingleItem().ShouldBe(250);
}
/// <summary>Verifies that the constructor rejects negative buffered intervals.</summary>
[Fact]
public void Constructor_RejectsNegativeBufferedInterval()
{
@@ -107,6 +124,7 @@ public sealed class PerPlatformProbeWatcherTests
new PerPlatformProbeWatcher(subscriber, new HostStatusAggregator(), bufferedUpdateIntervalMs: -1));
}
/// <summary>Verifies that syncing the same platform set twice does not resubscribe.</summary>
[Fact]
public async Task SyncPlatformsAsync_SameSetTwice_DoesNotResubscribe()
{
@@ -120,6 +138,7 @@ public sealed class PerPlatformProbeWatcherTests
subscriber.Subscribes.Count.ShouldBe(1);
}
/// <summary>Verifies that removed platforms are unsubscribed and dropped from the aggregator.</summary>
[Fact]
public async Task SyncPlatformsAsync_RemovedPlatforms_AreUnsubscribed_AndDroppedFromAggregator()
{
@@ -142,6 +161,10 @@ public sealed class PerPlatformProbeWatcherTests
aggregator.Snapshot().Any(s => s.HostName == "B").ShouldBeFalse();
}
/// <summary>Verifies that DecodeState correctly decodes ScanState values and quality bytes across multiple pin configurations.</summary>
/// <param name="value">The probe value to decode.</param>
/// <param name="qualityByte">The OPC UA quality byte indicating data validity.</param>
/// <param name="expected">The expected decoded host state.</param>
[Theory]
[InlineData(true, (byte)192, HostState.Running)]
[InlineData(false, (byte)192, HostState.Stopped)]
@@ -159,6 +182,7 @@ public sealed class PerPlatformProbeWatcherTests
PerPlatformProbeWatcher.DecodeState(value, qualityByte).ShouldBe(expected);
}
/// <summary>Verifies that a running probe value is routed to the aggregator.</summary>
[Fact]
public async Task OnProbeValueChanged_Running_RoutesToAggregator()
{
@@ -173,6 +197,7 @@ public sealed class PerPlatformProbeWatcherTests
snap.State.ShouldBe(HostState.Running);
}
/// <summary>Verifies that a probe value with bad quality routes as unknown state.</summary>
[Fact]
public async Task OnProbeValueChanged_BadQuality_RoutesUnknown()
{
@@ -186,6 +211,7 @@ public sealed class PerPlatformProbeWatcherTests
aggregator.Snapshot().Single(s => s.HostName == "PlatformA").State.ShouldBe(HostState.Unknown);
}
/// <summary>Verifies that foreign probe references are silently dropped.</summary>
[Fact]
public async Task OnProbeValueChanged_ForeignReference_IsSilentlyDropped()
{
@@ -204,6 +230,7 @@ public sealed class PerPlatformProbeWatcherTests
aggregator.Snapshot().Any(s => s.HostName == "Stranger").ShouldBeFalse();
}
/// <summary>Verifies that dispose unsubscribes all tracked platforms.</summary>
[Fact]
public async Task Dispose_UnsubscribesAllTrackedPlatforms()
{
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// </summary>
public sealed class EventPumpBoundedChannelTests
{
/// <summary>Verifies that the event pump drops newest events when the bounded channel fills and records metrics for dropped events.</summary>
[Fact]
public async Task Drops_newest_when_channel_fills_and_records_metric()
{
@@ -66,6 +67,7 @@ public sealed class EventPumpBoundedChannelTests
}
}
/// <summary>Verifies that the event pump throws an exception when the channel capacity is invalid.</summary>
[Fact]
public async Task Throws_when_channelCapacity_is_invalid()
{
@@ -78,6 +80,7 @@ public sealed class EventPumpBoundedChannelTests
await Task.CompletedTask;
}
/// <summary>Verifies that event pump metrics are tagged with the client name for tracking multiple driver hosts.</summary>
[Fact]
public async Task Tags_metrics_with_client_name_for_multi_driver_hosts()
{
@@ -169,10 +172,15 @@ public sealed class EventPumpBoundedChannelTests
{
public MeterListener? Listener;
internal long _received, _dispatched, _dropped;
/// <summary>Gets the count of received events.</summary>
public long Received => Interlocked.Read(ref _received);
/// <summary>Gets the count of dispatched events.</summary>
public long Dispatched => Interlocked.Read(ref _dispatched);
/// <summary>Gets the count of dropped events.</summary>
public long Dropped => Interlocked.Read(ref _dropped);
/// <summary>Gets the count of in-flight events.</summary>
public long InFlight => Math.Max(0, Received - Dispatched - Dropped);
/// <summary>Disposes the meter listener.</summary>
public void Dispose() => Listener?.Dispose();
}
@@ -181,16 +189,32 @@ public sealed class EventPumpBoundedChannelTests
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
/// <summary>Subscribes to a bulk list of tag references.</summary>
/// <param name="fullReferences">The list of full references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An empty result list.</returns>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
/// <summary>Unsubscribes from a bulk list of item handles.</summary>
/// <param name="itemHandles">The list of item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A completed task.</returns>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams events asynchronously.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async enumerable of MxEvent objects.</returns>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
/// <summary>Emits a test event with the specified item handle and value.</summary>
/// <param name="itemHandle">The item handle.</param>
/// <param name="value">The event value.</param>
/// <returns>A completed value task.</returns>
public ValueTask EmitAsync(int itemHandle, double value) =>
_stream.Writer.WriteAsync(new MxEvent
{
@@ -18,6 +18,7 @@ public sealed class EventPumpStreamFaultTests
{
private const int WaitMs = 2_000;
/// <summary>Verifies that stream fault invokes the callback with the exception.</summary>
[Fact]
public async Task StreamFault_InvokesOnStreamFaultCallback_WithTheCause()
{
@@ -40,6 +41,7 @@ public sealed class EventPumpStreamFaultTests
(await faultObserved.Task).ShouldBeOfType<IOException>();
}
/// <summary>Verifies that stream fault drives the reconnect supervisor through reopen and replay.</summary>
[Fact]
public async Task StreamFault_DrivesReconnectSupervisorReopenReplay()
{
@@ -76,6 +78,7 @@ public sealed class EventPumpStreamFaultTests
supervisor.IsDegraded.ShouldBeFalse();
}
/// <summary>Verifies that a faulted pump cannot be restarted in place, but a fresh pump resumes dispatch.</summary>
[Fact]
public async Task FaultedPump_IsNotRestartableInPlace_ButAFreshPumpResumesDispatch()
{
@@ -118,6 +121,7 @@ public sealed class EventPumpStreamFaultTests
staleObserved.ShouldBeFalse("the faulted pump must not dispatch after its stream dropped");
}
/// <summary>Verifies that clean shutdown does not invoke the stream fault callback.</summary>
[Fact]
public async Task CleanShutdown_DoesNotInvokeOnStreamFault()
{
@@ -146,17 +150,27 @@ public sealed class EventPumpStreamFaultTests
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
/// <summary>Subscribes to multiple tags (test stub).</summary>
/// <param name="fullReferences">The tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
/// <summary>Unsubscribes from multiple tags (test stub).</summary>
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams events asynchronously (test stub).</summary>
/// <param name="cancellationToken">The cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
/// <summary>Fault the stream so the pump's <c>await foreach</c> throws.</summary>
/// <param name="cause">The exception to complete the stream with.</param>
public void FaultStream(Exception cause) => _stream.Writer.TryComplete(cause);
}
@@ -169,16 +183,28 @@ public sealed class EventPumpStreamFaultTests
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
/// <summary>Subscribes to multiple tags (test stub).</summary>
/// <param name="fullReferences">The tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
/// <summary>Unsubscribes from multiple tags (test stub).</summary>
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams events asynchronously (test stub).</summary>
/// <param name="cancellationToken">The cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
/// <summary>Emits a data change event asynchronously.</summary>
/// <param name="itemHandle">The item handle for the data change.</param>
/// <param name="value">The numeric value of the change.</param>
public ValueTask EmitAsync(int itemHandle, double value) =>
_stream.Writer.WriteAsync(new MxEvent
{
@@ -23,7 +23,9 @@ public sealed class GalaxyDriverReadTests
private sealed class FakeReader : IGalaxyDataReader
{
/// <summary>Gets the last read request.</summary>
public IReadOnlyList<string>? LastRequest { get; private set; }
/// <summary>Gets or sets the function that decides the result for a given tag list.</summary>
public Func<IReadOnlyList<string>, IReadOnlyList<DataValueSnapshot>> Decide { get; set; } =
tags => tags.Select(t => new DataValueSnapshot(
Value: t,
@@ -31,6 +33,7 @@ public sealed class GalaxyDriverReadTests
SourceTimestampUtc: DateTime.UtcNow,
ServerTimestampUtc: DateTime.UtcNow)).ToArray();
/// <inheritdoc />
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
@@ -39,6 +42,7 @@ public sealed class GalaxyDriverReadTests
}
}
/// <summary>Verifies that ReadAsync routes through the injected reader.</summary>
[Fact]
public async Task ReadAsync_RoutesThroughInjectedReader()
{
@@ -53,6 +57,7 @@ public sealed class GalaxyDriverReadTests
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
}
/// <summary>Verifies that ReadAsync returns empty without calling the reader for an empty request.</summary>
[Fact]
public async Task ReadAsync_EmptyRequest_ReturnsEmpty_WithoutCallingReader()
{
@@ -65,6 +70,7 @@ public sealed class GalaxyDriverReadTests
reader.LastRequest.ShouldBeNull();
}
/// <summary>Verifies that ReadAsync throws when seams and production runtime are not built.</summary>
[Fact]
public async Task ReadAsync_NoSeams_AndNoProductionRuntime_Throws()
{
@@ -79,6 +85,7 @@ public sealed class GalaxyDriverReadTests
ex.Message.ShouldContain("production runtime not built");
}
/// <summary>Verifies that ReadAsync throws after the driver is disposed.</summary>
[Fact]
public async Task ReadAsync_AfterDispose_Throws()
{
@@ -88,6 +95,7 @@ public sealed class GalaxyDriverReadTests
driver.ReadAsync(["x"], CancellationToken.None));
}
/// <summary>Verifies that ReadAsync resolves from the first OnDataChange event on the subscribe-once path.</summary>
[Fact]
public async Task ReadAsync_SubscribeOncePath_ResolvesFromFirstOnDataChange()
{
@@ -111,6 +119,7 @@ public sealed class GalaxyDriverReadTests
subscriber.UnsubscribedHandles.ShouldContain(itemHandle);
}
/// <summary>Verifies that ReadAsync surfaces rejected tags as bad status on the subscribe-once path.</summary>
[Fact]
public async Task ReadAsync_SubscribeOncePath_RejectedTagSurfacesAsBadStatus()
{
@@ -130,6 +139,7 @@ public sealed class GalaxyDriverReadTests
result[1].StatusCode.ShouldBe(0x80000000u); // Bad
}
/// <summary>Verifies that ReadAsync preserves reader status codes.</summary>
[Fact]
public async Task ReadAsync_PreservesReaderStatusCodes()
{
@@ -27,11 +27,24 @@ public sealed class GalaxyDriverSubscribeTests
{
private int _nextHandle = 1;
private readonly Channel<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
/// <summary>Gets the mapping of tag references to subscription handles.</summary>
public Dictionary<string, int> Map { get; } = new();
/// <summary>Gets the list of unsubscribed handles.</summary>
public List<int> UnsubscribedHandles { get; } = [];
/// <summary>Gets the list of buffered intervals called.</summary>
public List<int> BufferedIntervalsCalled { get; } = [];
/// <summary>Gets or sets a function to decide whether to accept a subscription.</summary>
public Func<string, bool> Decide { get; set; } = _ => true;
/// <summary>Subscribes to bulk updates for the specified tag references.</summary>
/// <param name="fullReferences">The tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A list of subscription results.</returns>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -64,15 +77,27 @@ public sealed class GalaxyDriverSubscribeTests
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Unsubscribes from bulk updates for the specified item handles.</summary>
/// <param name="itemHandles">The handles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
UnsubscribedHandles.AddRange(itemHandles);
return Task.CompletedTask;
}
/// <summary>Streams events asynchronously.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of MX events.</returns>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _events.Reader.ReadAllAsync(cancellationToken);
/// <summary>Emits a data change event asynchronously.</summary>
/// <param name="itemHandle">The handle of the item that changed.</param>
/// <param name="value">The new value.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A value task representing the asynchronous emission.</returns>
public ValueTask EmitOnDataChangeAsync(int itemHandle, double value, byte quality = 192) =>
_events.Writer.WriteAsync(new MxEvent
{
@@ -83,9 +108,11 @@ public sealed class GalaxyDriverSubscribeTests
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
});
/// <summary>Completes the event stream.</summary>
public void CompleteEvents() => _events.Writer.Complete();
}
/// <summary>Verifies subscription allocates a handle and dispatches value changes.</summary>
[Fact]
public async Task SubscribeAsync_AllocatesHandle_AndDispatchesValueChange()
{
@@ -108,6 +135,7 @@ public sealed class GalaxyDriverSubscribeTests
((double)captured[0].Snapshot.Value!).ShouldBe(42.0);
}
/// <summary>Verifies two subscriptions for the same tag each receive updates.</summary>
[Fact]
public async Task SubscribeAsync_TwoSubscriptions_SameTag_FanOutOnePerSubscription()
{
@@ -151,6 +179,7 @@ public sealed class GalaxyDriverSubscribeTests
allowed.ShouldContain(captured0Id);
}
/// <summary>Verifies failed subscriptions do not dispatch events.</summary>
[Fact]
public async Task SubscribeAsync_FailedTag_DoesNotDispatchEvents()
{
@@ -171,6 +200,7 @@ public sealed class GalaxyDriverSubscribeTests
captured.ShouldBeEmpty();
}
/// <summary>Verifies unsubscribe removes registration and calls gateway unsubscribe.</summary>
[Fact]
public async Task UnsubscribeAsync_RemovesRegistration_AndCallsGwUnsubscribe()
{
@@ -193,6 +223,7 @@ public sealed class GalaxyDriverSubscribeTests
captured.ShouldBeEmpty();
}
/// <summary>Verifies unsubscribing with an unknown handle is handled.</summary>
[Fact]
public async Task UnsubscribeAsync_UnknownHandle_NoOp()
{
@@ -207,6 +238,7 @@ public sealed class GalaxyDriverSubscribeTests
driver.UnsubscribeAsync(foreignHandle, CancellationToken.None));
}
/// <summary>Verifies subscription without a subscriber throws.</summary>
[Fact]
public async Task SubscribeAsync_NoSubscriber_Throws()
{
@@ -216,6 +248,7 @@ public sealed class GalaxyDriverSubscribeTests
ex.Message.ShouldContain("PR 4.W");
}
/// <summary>Verifies subscription falls back to configured interval when zero is passed.</summary>
[Fact]
public async Task SubscribeAsync_FallsBackToConfiguredInterval_WhenCallerPassesZero()
{
@@ -235,6 +268,7 @@ public sealed class GalaxyDriverSubscribeTests
subscriber.BufferedIntervalsCalled.ShouldHaveSingleItem().ShouldBe(750);
}
/// <summary>Verifies subscription respects caller's interval when non-zero.</summary>
[Fact]
public async Task SubscribeAsync_RespectsCallerInterval_WhenNonZero()
{
@@ -254,6 +288,7 @@ public sealed class GalaxyDriverSubscribeTests
subscriber.BufferedIntervalsCalled.ShouldHaveSingleItem().ShouldBe(250);
}
/// <summary>Verifies subscription with empty tag list returns handle without calling gateway.</summary>
[Fact]
public async Task SubscribeAsync_EmptyTagList_ReturnsHandleWithoutCallingGw()
{
@@ -266,8 +301,10 @@ public sealed class GalaxyDriverSubscribeTests
subscriber.Map.ShouldBeEmpty();
}
/// <summary>A subscription handle from a foreign source.</summary>
private sealed class ForeignHandle : ISubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for this handle.</summary>
public string DiagnosticId => "foreign-x";
}
@@ -26,34 +26,61 @@ public sealed class GalaxyDriverWriteTests
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
{
/// <summary>Returns the fake Galaxy object hierarchy.</summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
=> Task.FromResult(objects);
}
private sealed class FakeBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of variables added to this builder.</summary>
public List<DriverAttributeInfo> Variables { get; } = [];
/// <summary>Adds a folder and returns this builder for chaining.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
/// <summary>Adds a variable to the variables list and returns a handle.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="attributeInfo">The attribute information for the variable.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add(attributeInfo);
return new FakeHandle(attributeInfo.FullName);
}
/// <summary>No-op property adding operation for test compatibility.</summary>
/// <param name="browseName">The browse name of the property.</param>
/// <param name="dataType">The data type of the property.</param>
/// <param name="value">The value of the property.</param>
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class FakeHandle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference for this variable handle.</summary>
public string FullReference { get; } = fullRef;
/// <summary>Marks this variable as an alarm condition and returns a noop sink.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
private sealed class NoopSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
/// <summary>No-op alarm transition handler.</summary>
private sealed class NoopSink : IAlarmConditionSink {
/// <summary>Handles alarm state transition events.</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
private sealed class FakeWriter : IGalaxyDataWriter
{
/// <summary>Gets the list of write calls received by this writer.</summary>
public List<(string FullRef, object? Value, SecurityClassification Resolved)> Calls { get; } = [];
/// <summary>Records write requests with their resolved security classifications.</summary>
/// <param name="writes">The list of write requests to process.</param>
/// <param name="securityResolver">Function to resolve security classification for each request.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
Func<string, SecurityClassification> securityResolver,
@@ -79,6 +106,7 @@ public sealed class GalaxyDriverWriteTests
return o;
}
/// <summary>Verifies that WriteAsync routes through the injected writer and propagates values correctly.</summary>
[Fact]
public async Task WriteAsync_RoutesThroughInjectedWriter_AndPropagatesValues()
{
@@ -102,6 +130,9 @@ public sealed class GalaxyDriverWriteTests
writer.Calls[1].Resolved.ShouldBe(SecurityClassification.Operate);
}
/// <summary>Verifies that WriteAsync resolves every security classification from discovery data.</summary>
/// <param name="mxSec">The raw MXAccess security integer from the discovery attribute.</param>
/// <param name="expected">The expected resolved security classification.</param>
[Theory]
[InlineData(0, SecurityClassification.FreeAccess)]
[InlineData(1, SecurityClassification.Operate)]
@@ -125,6 +156,7 @@ public sealed class GalaxyDriverWriteTests
writer.Calls[0].Resolved.ShouldBe(expected);
}
/// <summary>Verifies that unknown tags resolve to FreeAccess classification and writes proceed.</summary>
[Fact]
public async Task WriteAsync_UnknownTag_ResolvesToFreeAccess_DefaultsToWrite()
{
@@ -139,6 +171,7 @@ public sealed class GalaxyDriverWriteTests
writer.Calls[0].Resolved.ShouldBe(SecurityClassification.FreeAccess);
}
/// <summary>Verifies that an empty write request returns empty without calling the writer.</summary>
[Fact]
public async Task WriteAsync_EmptyRequest_ReturnsEmpty_WithoutCallingWriter()
{
@@ -152,6 +185,7 @@ public sealed class GalaxyDriverWriteTests
writer.Calls.ShouldBeEmpty();
}
/// <summary>Verifies that WriteAsync throws when no writer is configured, referencing PR 4.4.</summary>
[Fact]
public async Task WriteAsync_NoWriter_Throws_PointingAtPR44()
{
@@ -162,6 +196,7 @@ public sealed class GalaxyDriverWriteTests
ex.Message.ShouldContain("PR 4.4");
}
/// <summary>Verifies that WriteAsync throws ObjectDisposedException after the driver is disposed.</summary>
[Fact]
public async Task WriteAsync_AfterDispose_Throws()
{
@@ -30,6 +30,7 @@ public sealed class GalaxyTelemetryTests
return (listener, captured);
}
/// <summary>Verifies that TracedGalaxySubscriber emits a subscribe_bulk span with tag count.</summary>
[Fact]
public async Task TracedGalaxySubscriber_emits_subscribe_bulk_span_with_tag_count()
{
@@ -50,6 +51,7 @@ public sealed class GalaxyTelemetryTests
finally { listener.Dispose(); }
}
/// <summary>Verifies that TracedGalaxySubscriber records error and rethrows on failure.</summary>
[Fact]
public async Task TracedGalaxySubscriber_records_error_and_rethrows_on_failure()
{
@@ -67,6 +69,7 @@ public sealed class GalaxyTelemetryTests
finally { listener.Dispose(); }
}
/// <summary>Verifies that TracedGalaxyDataWriter tags the secured write count.</summary>
[Fact]
public async Task TracedGalaxyDataWriter_tags_secured_write_count()
{
@@ -102,6 +105,7 @@ public sealed class GalaxyTelemetryTests
finally { listener.Dispose(); }
}
/// <summary>Verifies that TracedGalaxyHierarchySource tags the object count.</summary>
[Fact]
public async Task TracedGalaxyHierarchySource_tags_object_count()
{
@@ -121,6 +125,7 @@ public sealed class GalaxyTelemetryTests
private sealed class FakeSubscriber : IGalaxySubscriber
{
/// <inheritdoc />
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>(
@@ -131,9 +136,11 @@ public sealed class GalaxyTelemetryTests
WasSuccessful = true,
}).ToList());
/// <inheritdoc />
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -144,13 +151,16 @@ public sealed class GalaxyTelemetryTests
private sealed class ThrowingSubscriber : IGalaxySubscriber
{
/// <inheritdoc />
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> throw new InvalidOperationException("gw down");
/// <inheritdoc />
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -161,6 +171,7 @@ public sealed class GalaxyTelemetryTests
private sealed class RecordingWriter : IGalaxyDataWriter
{
/// <inheritdoc />
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
Func<string, SecurityClassification> securityResolver,
@@ -171,6 +182,7 @@ public sealed class GalaxyTelemetryTests
private sealed class FakeHierarchy : IGalaxyHierarchySource
{
/// <inheritdoc />
public Task<IReadOnlyList<MxGateway.Contracts.Proto.Galaxy.GalaxyObject>> GetHierarchyAsync(
CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<MxGateway.Contracts.Proto.Galaxy.GalaxyObject>>(
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// </summary>
public sealed class GatewayGalaxyAlarmFeedTests
{
/// <summary>Verifies that the feed decodes active alarm snapshots and live transitions.</summary>
[Fact]
public async Task Decodes_active_alarm_snapshot_then_live_transition()
{
@@ -68,6 +69,7 @@ public sealed class GatewayGalaxyAlarmFeedTests
observed[2].OriginalRaiseTimestampUtc.ShouldBe(raise);
}
/// <summary>Verifies that the feed drops transitions with unspecified kind and empty messages.</summary>
[Fact]
public async Task Drops_transition_with_unspecified_kind_and_empty_message()
{
@@ -104,6 +106,7 @@ public sealed class GatewayGalaxyAlarmFeedTests
observed[0].SeverityBucket.ShouldBe(AlarmSeverity.High);
}
/// <summary>Verifies that the feed reopens the stream after a transport fault.</summary>
[Fact]
public async Task Reopens_stream_after_a_transport_fault()
{
@@ -13,6 +13,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// </summary>
public sealed class MxAccessSeverityMapperTests
{
/// <summary>Verifies that Map assigns the expected severity bucket and OPC UA severity value.</summary>
/// <param name="rawMxAccessSeverity">The raw MxAccess severity integer to map.</param>
/// <param name="expectedBucket">The expected AlarmSeverity bucket.</param>
/// <param name="expectedOpcUaSeverity">The expected OPC UA numeric severity value.</param>
[Theory]
[InlineData(0, AlarmSeverity.Low, MxAccessSeverityMapper.OpcUaSeverityLow)]
[InlineData(1, AlarmSeverity.Low, MxAccessSeverityMapper.OpcUaSeverityLow)]
@@ -32,6 +36,7 @@ public sealed class MxAccessSeverityMapperTests
opcUa.ShouldBe(expectedOpcUaSeverity);
}
/// <summary>Verifies that Map clamps negative severities into the low bucket.</summary>
[Fact]
public void Map_clamps_negative_severities_into_low_bucket()
{
@@ -14,12 +14,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// </summary>
public sealed class MxValueDecoderTests
{
/// <summary>Verifies that decoding null returns null.</summary>
[Fact]
public void Decode_Null_ReturnsNull()
{
MxValueDecoder.Decode(null).ShouldBeNull();
}
/// <summary>Verifies that decoding IsNull flag returns null.</summary>
[Fact]
public void Decode_IsNullFlag_ReturnsNull()
{
@@ -27,24 +29,31 @@ public sealed class MxValueDecoderTests
MxValueDecoder.Decode(v).ShouldBeNull();
}
/// <summary>Verifies that bool values decode correctly.</summary>
[Fact]
public void Decode_Bool() => MxValueDecoder.Decode(new MxValue { BoolValue = true }).ShouldBe(true);
/// <summary>Verifies that Int32 values decode correctly.</summary>
[Fact]
public void Decode_Int32() => MxValueDecoder.Decode(new MxValue { Int32Value = -42 }).ShouldBe(-42);
/// <summary>Verifies that Int64 values decode correctly.</summary>
[Fact]
public void Decode_Int64() => MxValueDecoder.Decode(new MxValue { Int64Value = 123456789012L }).ShouldBe(123456789012L);
/// <summary>Verifies that float values decode correctly.</summary>
[Fact]
public void Decode_Float() => MxValueDecoder.Decode(new MxValue { FloatValue = 3.14f }).ShouldBe(3.14f);
/// <summary>Verifies that double values decode correctly.</summary>
[Fact]
public void Decode_Double() => MxValueDecoder.Decode(new MxValue { DoubleValue = 2.71828 }).ShouldBe(2.71828);
/// <summary>Verifies that string values decode correctly.</summary>
[Fact]
public void Decode_String() => MxValueDecoder.Decode(new MxValue { StringValue = "hello" }).ShouldBe("hello");
/// <summary>Verifies that timestamp values decode to UTC datetime.</summary>
[Fact]
public void Decode_Timestamp_ReturnsUtcDateTime()
{
@@ -53,6 +62,7 @@ public sealed class MxValueDecoderTests
MxValueDecoder.Decode(v).ShouldBe(when);
}
/// <summary>Verifies that bool arrays decode correctly.</summary>
[Fact]
public void Decode_BoolArray()
{
@@ -69,6 +79,7 @@ public sealed class MxValueDecoderTests
decoded.ShouldBe(new[] { true, false, true });
}
/// <summary>Verifies that double arrays decode correctly.</summary>
[Fact]
public void Decode_DoubleArray()
{
@@ -82,6 +93,7 @@ public sealed class MxValueDecoderTests
decoded.ShouldBe(new[] { 1.0, 2.0, 3.5 });
}
/// <summary>Verifies that string arrays decode correctly.</summary>
[Fact]
public void Decode_StringArray()
{
@@ -95,6 +107,7 @@ public sealed class MxValueDecoderTests
decoded.ShouldBe(new[] { "a", "b" });
}
/// <summary>Verifies that raw byte values decode correctly.</summary>
[Fact]
public void Decode_RawValue_ReturnsBytes()
{
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// </summary>
public sealed class MxValueEncoderTests
{
/// <summary>Verifies that encoding a null value sets the IsNull flag.</summary>
[Fact]
public void Encode_Null_SetsIsNullFlag()
{
@@ -19,9 +20,13 @@ public sealed class MxValueEncoderTests
v.IsNull.ShouldBeTrue();
}
/// <summary>Verifies that encoding a boolean value encodes correctly.</summary>
[Fact]
public void Encode_Bool() => MxValueEncoder.Encode(true).BoolValue.ShouldBe(true);
/// <summary>Verifies that narrow signed and unsigned types fit into Int32 encoding.</summary>
/// <param name="input">The input value to encode.</param>
/// <param name="expected">The expected encoded integer value.</param>
[Theory]
[InlineData((sbyte)-5, -5)]
[InlineData((short)-1000, -1000)]
@@ -34,9 +39,11 @@ public sealed class MxValueEncoderTests
v.Int32Value.ShouldBe(expected);
}
/// <summary>Verifies that Int32 values round-trip through encoding.</summary>
[Fact]
public void Encode_Int32_RoundTrip() => MxValueEncoder.Encode(int.MinValue).Int32Value.ShouldBe(int.MinValue);
/// <summary>Verifies that Int64 values round-trip through encoding.</summary>
[Fact]
public void Encode_Int64_RoundTrip()
{
@@ -45,18 +52,23 @@ public sealed class MxValueEncoderTests
v.Int64Value.ShouldBe(long.MaxValue);
}
/// <summary>Verifies that UInt32 values fit in Int32 encoding.</summary>
[Fact]
public void Encode_UInt32_FitsInInt32() => MxValueEncoder.Encode((uint)int.MaxValue).Int32Value.ShouldBe(int.MaxValue);
/// <summary>Verifies that float values encode correctly.</summary>
[Fact]
public void Encode_Float() => MxValueEncoder.Encode(3.14f).FloatValue.ShouldBe(3.14f);
/// <summary>Verifies that double values encode correctly.</summary>
[Fact]
public void Encode_Double() => MxValueEncoder.Encode(2.71828).DoubleValue.ShouldBe(2.71828);
/// <summary>Verifies that string values encode correctly.</summary>
[Fact]
public void Encode_String() => MxValueEncoder.Encode("hello").StringValue.ShouldBe("hello");
/// <summary>Verifies that UTC DateTime values encode correctly.</summary>
[Fact]
public void Encode_DateTimeUtc()
{
@@ -66,6 +78,7 @@ public sealed class MxValueEncoderTests
v.TimestampValue.ToDateTime().ShouldBe(when);
}
/// <summary>Verifies that local DateTime values are converted to UTC during encoding.</summary>
[Fact]
public void Encode_DateTimeLocal_ConvertsToUtc()
{
@@ -74,6 +87,7 @@ public sealed class MxValueEncoderTests
v.TimestampValue.ToDateTime().ShouldBe(local.ToUniversalTime());
}
/// <summary>Verifies that boolean arrays encode correctly.</summary>
[Fact]
public void Encode_BoolArray()
{
@@ -82,6 +96,7 @@ public sealed class MxValueEncoderTests
v.ArrayValue.Dimensions[0].ShouldBe(3u);
}
/// <summary>Verifies that double arrays encode correctly.</summary>
[Fact]
public void Encode_DoubleArray()
{
@@ -89,6 +104,7 @@ public sealed class MxValueEncoderTests
v.ArrayValue.DoubleValues.Values.ToArray().ShouldBe(new[] { 1.0, 2.0, 3.5 });
}
/// <summary>Verifies that string arrays encode correctly.</summary>
[Fact]
public void Encode_StringArray()
{
@@ -96,6 +112,7 @@ public sealed class MxValueEncoderTests
v.ArrayValue.StringValues.Values.ToArray().ShouldBe(new[] { "a", "b" });
}
/// <summary>Verifies that all DateTime values in arrays are converted to UTC during encoding.</summary>
[Fact]
public void Encode_DateTimeArray_ConvertsAllToUtc()
{
@@ -104,12 +121,14 @@ public sealed class MxValueEncoderTests
v.ArrayValue.TimestampValues.Values[0].ToDateTime().ShouldBe(inputs[0]);
}
/// <summary>Verifies that encoding an unsupported type throws an exception.</summary>
[Fact]
public void Encode_UnsupportedType_Throws()
{
Should.Throw<ArgumentException>(() => MxValueEncoder.Encode(new { Foo = 1 }));
}
/// <summary>Verifies that all scalar types round-trip correctly through encode and decode.</summary>
[Fact]
public void RoundTrip_AllScalarTypes_DecodeMatchesOriginal()
{
@@ -17,6 +17,7 @@ public sealed class ReconnectSupervisorTests
new(InitialBackoffOverride: TimeSpan.FromMilliseconds(5),
MaxBackoffOverride: TimeSpan.FromMilliseconds(20));
/// <summary>Verifies that the supervisor starts in a healthy state with no errors.</summary>
[Fact]
public void InitialState_IsHealthy()
{
@@ -26,6 +27,7 @@ public sealed class ReconnectSupervisorTests
sup.LastError.ShouldBeNull();
}
/// <summary>Verifies that reporting a transport failure drives the supervisor through reopen and replay cycles back to healthy.</summary>
[Fact]
public async Task ReportTransportFailure_DrivesThroughReopenReplay_BackToHealthy()
{
@@ -54,6 +56,7 @@ public sealed class ReconnectSupervisorTests
sup.IsDegraded.ShouldBeFalse();
}
/// <summary>Verifies that reopen failures trigger retries and the supervisor stays in reopening state between attempts.</summary>
[Fact]
public async Task ReopenFailure_RetriesUntilSuccess_StaysInReopeningBetweenAttempts()
{
@@ -71,6 +74,7 @@ public sealed class ReconnectSupervisorTests
sup.LastError.ShouldBeNull(); // cleared on Healthy transition
}
/// <summary>Verifies that replay failures trigger a retry of the entire reopen-replay cycle.</summary>
[Fact]
public async Task ReplayFailure_RetriesEntireCycle()
{
@@ -90,6 +94,7 @@ public sealed class ReconnectSupervisorTests
sup.CurrentState.ShouldBe(ReconnectSupervisor.State.Healthy);
}
/// <summary>Verifies that repeated failure reports during recovery do not spawn parallel recovery loops.</summary>
[Fact]
public async Task RepeatedFailureReports_DuringRecovery_DoNotSpawnParallelLoops()
{
@@ -116,6 +121,7 @@ public sealed class ReconnectSupervisorTests
attempts.ShouldBe(1);
}
/// <summary>Verifies that the last error reflects the most recent failure cause from recovery attempts.</summary>
[Fact]
public async Task LastError_ReflectsMostRecentFailureCause()
{
@@ -134,6 +140,7 @@ public sealed class ReconnectSupervisorTests
sup.LastError.ShouldContain("reopen broke"); // updates from the loop's failed reopen attempts
}
/// <summary>Verifies that disposing the supervisor cancels a running recovery loop cleanly.</summary>
[Fact]
public async Task Dispose_CancelsRunningRecoveryLoop_Cleanly()
{
@@ -153,6 +160,7 @@ public sealed class ReconnectSupervisorTests
cancelled.ShouldBeTrue();
}
/// <summary>Verifies that reporting a transport failure after dispose throws ObjectDisposedException.</summary>
[Fact]
public void ReportTransportFailure_AfterDispose_Throws()
{
@@ -161,6 +169,7 @@ public sealed class ReconnectSupervisorTests
Should.Throw<ObjectDisposedException>(() => sup.ReportTransportFailure(new IOException("x")));
}
/// <summary>Verifies that waiting for healthy state returns immediately when already healthy.</summary>
[Fact]
public async Task WaitForHealthy_ReturnsImmediately_WhenAlreadyHealthy()
{
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// </summary>
public sealed class StatusCodeMapTests
{
/// <summary>Verifies that known quality bytes map to the expected OPC UA status codes.</summary>
/// <param name="input">The Galaxy quality byte to map.</param>
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData((byte)192, 0x00000000u)] // Good
[InlineData((byte)216, 0x00D80000u)] // Good_LocalOverride
@@ -35,6 +38,8 @@ public sealed class StatusCodeMapTests
StatusCodeMap.FromQualityByte(input).ShouldBe(expected);
}
/// <summary>Verifies that unknown quality bytes fall back to category bucket status codes.</summary>
/// <param name="input">The unknown Galaxy quality byte to categorize.</param>
[Theory]
[InlineData((byte)200)] // Unknown Good — falls back to category bucket
[InlineData((byte)100)] // Unknown Uncertain
@@ -53,12 +58,14 @@ public sealed class StatusCodeMapTests
// would invert the mapping when the worker sets success=1 for an error code (the numeric
// value is NOT a boolean).
/// <summary>Verifies that null status maps to Good status code.</summary>
[Fact]
public void FromMxStatus_NullStatus_IsGood()
{
StatusCodeMap.FromMxStatus(null).ShouldBe(StatusCodeMap.Good);
}
/// <summary>Verifies that non-zero success with OK category maps to Good.</summary>
[Fact]
public void FromMxStatus_SuccessNonZeroAndCategoryOk_IsGood()
{
@@ -67,6 +74,7 @@ public sealed class StatusCodeMapTests
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Good);
}
/// <summary>Verifies that non-zero success with non-OK category does not map to Good.</summary>
[Fact]
public void FromMxStatus_SuccessNonZeroButCategoryNotOk_IsNotGood()
{
@@ -76,6 +84,7 @@ public sealed class StatusCodeMapTests
StatusCodeMap.FromMxStatus(s).ShouldNotBe(StatusCodeMap.Good);
}
/// <summary>Verifies that known detail codes map to their specific status codes.</summary>
[Fact]
public void FromMxStatus_SuccessZeroAndCategoryNotOk_DetailKnown_MapsToSpecificCode()
{
@@ -83,6 +92,7 @@ public sealed class StatusCodeMapTests
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadNotConnected);
}
/// <summary>Verifies that non-zero DetectedBy maps to communication error when detail is zero.</summary>
[Fact]
public void FromMxStatus_SuccessZero_DetailZero_DetectedByNonZero_IsCommunicationError()
{
@@ -90,6 +100,7 @@ public sealed class StatusCodeMapTests
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.BadCommunicationError);
}
/// <summary>Verifies that all-zero status maps to Bad status code.</summary>
[Fact]
public void FromMxStatus_SuccessZero_AllZero_IsBad()
{
@@ -97,6 +108,7 @@ public sealed class StatusCodeMapTests
StatusCodeMap.FromMxStatus(s).ShouldBe(StatusCodeMap.Bad);
}
/// <summary>Verifies that top two bits of status codes follow OPC UA convention.</summary>
[Fact]
public void TopByteCategoryBits_StayWithinOpcUaConvention()
{
@@ -115,6 +127,9 @@ public sealed class StatusCodeMapTests
// ===== Driver.Galaxy-004 regression: ToQualityCategoryByte lives next to its inverse =====
/// <summary>Verifies that status codes extract to their OPC DA category byte equivalent.</summary>
/// <param name="statusCode">The OPC UA status code to convert.</param>
/// <param name="expected">The expected OPC DA category byte.</param>
[Theory]
[InlineData(0x00000000u, (byte)192)] // Good
[InlineData(0x00D80000u, (byte)192)] // GoodLocalOverride — still Good category
@@ -128,6 +143,7 @@ public sealed class StatusCodeMapTests
StatusCodeMap.ToQualityCategoryByte(statusCode).ShouldBe(expected);
}
/// <summary>Verifies that ToQualityCategoryByte is the right inverse of FromQualityByte for category bytes.</summary>
[Fact]
public void ToQualityCategoryByte_IsRightInverseOfFromQualityByte_ForCategoryBytes()
{
@@ -11,6 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// </summary>
public sealed class SubscriptionRegistryTests
{
/// <summary>Verifies NextSubscriptionId() increments monotonically.</summary>
[Fact]
public void NextSubscriptionId_IsMonotonic()
{
@@ -20,6 +21,7 @@ public sealed class SubscriptionRegistryTests
registry.NextSubscriptionId().ShouldBe(3);
}
/// <summary>Verifies Register() and ResolveSubscribers() correctly store and return a single subscription.</summary>
[Fact]
public void Register_OneSubscription_OneTag_ResolvesSingleSubscriber()
{
@@ -32,6 +34,7 @@ public sealed class SubscriptionRegistryTests
subs[0].FullReference.ShouldBe("Tank.Level");
}
/// <summary>Verifies multiple subscriptions to the same tag are both indexed for fan-out.</summary>
[Fact]
public void Register_TwoSubscriptions_SameTag_FanOutToBoth()
{
@@ -44,6 +47,7 @@ public sealed class SubscriptionRegistryTests
subs.Select(s => s.SubscriptionId).OrderBy(x => x).ShouldBe(new[] { 1L, 2L });
}
/// <summary>Verifies failed item handles (rejected by gateway) are not indexed for fan-out.</summary>
[Fact]
public void Register_FailedItemHandle_NotIndexedForFanOut()
{
@@ -57,6 +61,7 @@ public sealed class SubscriptionRegistryTests
registry.ResolveSubscribers(0).ShouldBeEmpty();
}
/// <summary>Verifies Remove() drops all bindings and returns them.</summary>
[Fact]
public void Remove_DropsAllBindings_AndReturnsThemForUnsubscribe()
{
@@ -74,6 +79,7 @@ public sealed class SubscriptionRegistryTests
registry.ResolveSubscribers(200).ShouldBeEmpty();
}
/// <summary>Verifies removing one subscription of multiple leaves others intact.</summary>
[Fact]
public void Remove_OneOfTwoSubscriptions_LeavesOtherIntact()
{
@@ -88,6 +94,7 @@ public sealed class SubscriptionRegistryTests
subs[0].SubscriptionId.ShouldBe(2);
}
/// <summary>Verifies removing an unknown subscription returns null without error.</summary>
[Fact]
public void Remove_UnknownSubscription_IsNullSentinel()
{
@@ -95,6 +102,7 @@ public sealed class SubscriptionRegistryTests
registry.Remove(999).ShouldBeNull();
}
/// <summary>Verifies TrackedSubscriptionCount and TrackedItemHandleCount reflect registrations and removals.</summary>
[Fact]
public void TrackedCounts_ReflectAdditionsAndRemovals()
{
@@ -117,6 +125,7 @@ public sealed class SubscriptionRegistryTests
// ===== Driver.Galaxy-008 regression: reconnect replay rebinds with fresh handles =====
/// <summary>Verifies SnapshotEntries() groups bindings correctly by subscription ID.</summary>
[Fact]
public void SnapshotEntries_GroupsBindingsBySubscriptionId()
{
@@ -131,6 +140,7 @@ public sealed class SubscriptionRegistryTests
entries.Single(e => e.SubscriptionId == 2).Bindings.Count.ShouldBe(2);
}
/// <summary>Verifies Rebind() replaces stale handles with fresh post-reconnect handles.</summary>
[Fact]
public void Rebind_ReplacesStaleItemHandles_WithThePostReconnectHandles()
{
@@ -148,6 +158,7 @@ public sealed class SubscriptionRegistryTests
subs[0].FullReference.ShouldBe("Tank.Level");
}
/// <summary>Verifies Rebind() on one subscription does not affect others on the same old handle.</summary>
[Fact]
public void Rebind_LeavesOtherSubscriptionsOnTheSameOldHandleIntact()
{
@@ -162,6 +173,7 @@ public sealed class SubscriptionRegistryTests
registry.ResolveSubscribers(555).Select(s => s.SubscriptionId).ShouldBe(new[] { 1L });
}
/// <summary>Verifies Rebind() on an unknown subscription is a no-op.</summary>
[Fact]
public void Rebind_UnknownSubscription_IsNoOp()
{
@@ -170,6 +182,7 @@ public sealed class SubscriptionRegistryTests
registry.ResolveSubscribers(1).ShouldBeEmpty();
}
/// <summary>Verifies Rebind() does not index rejected item handles.</summary>
[Fact]
public void Rebind_FailedItemHandle_NotIndexedForFanOut()
{
@@ -185,6 +198,7 @@ public sealed class SubscriptionRegistryTests
// ===== Driver.Galaxy-012 regression: ResolveSubscribers is O(1) per binding =====
/// <summary>Verifies ResolveSubscribers() correctly dispatches in a large binding set without linear scan.</summary>
[Fact]
public void ResolveSubscribers_LargeBindingSet_DispatchesCorrectly()
{
@@ -215,23 +229,50 @@ public sealed class SubscriptionRegistryTests
// Internal types are accessed via friend assembly (InternalsVisibleTo); these
// wrapper aliases keep the test code readable.
/// <summary>Wrapper for accessing SubscriptionRegistry in tests via internal visibility.</summary>
private sealed class SubscriptionRegistryAccess
{
/// <summary>The underlying SubscriptionRegistry instance.</summary>
private readonly SubscriptionRegistry _inner = new();
/// <summary>Gets the count of tracked subscriptions.</summary>
public int TrackedSubscriptionCount => _inner.TrackedSubscriptionCount;
/// <summary>Gets the count of tracked item handles.</summary>
public int TrackedItemHandleCount => _inner.TrackedItemHandleCount;
/// <summary>Gets the next subscription ID.</summary>
public long NextSubscriptionId() => _inner.NextSubscriptionId();
/// <summary>Registers a subscription with the given bindings.</summary>
/// <param name="id">The subscription ID.</param>
/// <param name="bindings">The tag bindings to register.</param>
public void Register(long id, IReadOnlyList<TagBindingAccess> bindings)
=> _inner.Register(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]);
/// <summary>Removes a subscription and returns its bindings.</summary>
/// <param name="id">The subscription ID.</param>
/// <returns>The bindings that were removed, or null if not found.</returns>
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))];
}
/// <summary>Resolves subscribers for the given item handle.</summary>
/// <param name="handle">The item handle to look up.</param>
/// <returns>The list of subscriptions observing this handle.</returns>
public IReadOnlyList<(long SubscriptionId, string FullReference)> ResolveSubscribers(int handle)
=> _inner.ResolveSubscribers(handle);
/// <summary>Rebinds a subscription to new item handles.</summary>
/// <param name="id">The subscription ID.</param>
/// <param name="bindings">The new tag bindings.</param>
public void Rebind(long id, IReadOnlyList<TagBindingAccess> bindings)
=> _inner.Rebind(id, [.. bindings.Select(b => new TagBinding(b.FullReference, b.ItemHandle))]);
/// <summary>Snapshots all subscription entries grouped by ID.</summary>
/// <returns>All subscription entries with their bindings.</returns>
public IReadOnlyList<(long SubscriptionId, IReadOnlyList<TagBindingAccess> Bindings)> SnapshotEntries()
=> [.. _inner.SnapshotEntries().Select(e =>
(e.SubscriptionId,