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
@@ -5,25 +5,44 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
internal class FakeTwinCATClient : ITwinCATClient
{
/// <summary>Gets a value indicating whether the client is connected.</summary>
public bool IsConnected { get; private set; }
/// <summary>Gets the number of times Connect has been called.</summary>
public int ConnectCount { get; private set; }
/// <summary>Gets the number of times Dispose has been called.</summary>
public int DisposeCount { get; private set; }
/// <summary>Gets or sets a value indicating whether ConnectAsync should throw.</summary>
public bool ThrowOnConnect { get; set; }
/// <summary>Gets or sets a value indicating whether ReadValueAsync should throw.</summary>
public bool ThrowOnRead { get; set; }
/// <summary>Gets or sets a value indicating whether WriteValueAsync should throw.</summary>
public bool ThrowOnWrite { get; set; }
/// <summary>Gets or sets a value indicating whether ProbeAsync should throw.</summary>
public bool ThrowOnProbe { get; set; }
/// <summary>Gets or sets the exception to throw when a throw flag is set.</summary>
public Exception? Exception { get; set; }
/// <summary>Gets the simulated values by symbol path.</summary>
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the read statuses by symbol path.</summary>
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the write statuses by symbol path.</summary>
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the log of all write operations.</summary>
public List<(string symbol, TwinCATDataType type, int? bit, object? value)> WriteLog { get; } = new();
/// <summary>Gets or sets the result returned by ProbeAsync.</summary>
public bool ProbeResult { get; set; } = true;
/// <summary>Occurs when the symbol version changes.</summary>
public event EventHandler? OnSymbolVersionChanged;
/// <summary>Test hook — fire the symbol-version-changed signal as the real client would.</summary>
public void FireSymbolVersionChanged() => OnSymbolVersionChanged?.Invoke(this, EventArgs.Empty);
/// <summary>Simulates connecting to the TwinCAT system.</summary>
/// <param name="address">The AMS address to connect to.</param>
/// <param name="timeout">The connection timeout.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that completes when the connection succeeds or fails.</returns>
public virtual Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
{
ConnectCount++;
@@ -32,6 +51,12 @@ internal class FakeTwinCATClient : ITwinCATClient
return Task.CompletedTask;
}
/// <summary>Simulates reading a value from a symbol.</summary>
/// <param name="symbolPath">The path to the symbol to read.</param>
/// <param name="type">The data type of the symbol.</param>
/// <param name="bitIndex">The optional bit index for bit-level reads.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the simulated value and status.</returns>
public virtual Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, CancellationToken ct)
{
@@ -41,6 +66,13 @@ internal class FakeTwinCATClient : ITwinCATClient
return Task.FromResult((value, status));
}
/// <summary>Simulates writing a value to a symbol.</summary>
/// <param name="symbolPath">The path to the symbol to write.</param>
/// <param name="type">The data type of the symbol.</param>
/// <param name="bitIndex">The optional bit index for bit-level writes.</param>
/// <param name="value">The value to write.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the write status.</returns>
public virtual Task<uint> WriteValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{
@@ -51,12 +83,16 @@ internal class FakeTwinCATClient : ITwinCATClient
return Task.FromResult(status);
}
/// <summary>Simulates probing the connection status.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the probe result.</returns>
public virtual Task<bool> ProbeAsync(CancellationToken ct)
{
if (ThrowOnProbe) return Task.FromResult(false);
return Task.FromResult(ProbeResult);
}
/// <summary>Releases unmanaged resources.</summary>
public virtual void Dispose()
{
DisposeCount++;
@@ -65,11 +101,22 @@ internal class FakeTwinCATClient : ITwinCATClient
// ---- notification fake ----
/// <summary>Gets the list of registered notifications.</summary>
public List<FakeNotification> Notifications { get; } = new();
/// <summary>Gets or sets a value indicating whether AddNotificationAsync should throw.</summary>
public bool ThrowOnAddNotification { get; set; }
/// <summary>Records the most recently-supplied <c>maxDelayMs</c> for Driver.TwinCAT-014 tests.</summary>
public int LastMaxDelayMs { get; private set; }
/// <summary>Simulates adding a notification for value changes.</summary>
/// <param name="symbolPath">The path to the symbol to watch.</param>
/// <param name="type">The data type of the symbol.</param>
/// <param name="bitIndex">The optional bit index for bit-level notifications.</param>
/// <param name="cycleTime">The sampling cycle time.</param>
/// <param name="maxDelayMs">The maximum delay in milliseconds.</param>
/// <param name="onChange">The callback to invoke on value change.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns a notification handle.</returns>
public virtual Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
@@ -84,6 +131,8 @@ internal class FakeTwinCATClient : ITwinCATClient
}
/// <summary>Fire a change event through the registered callback for <paramref name="symbolPath"/>.</summary>
/// <param name="symbolPath">The symbol path for which to fire the change.</param>
/// <param name="value">The new value to pass to the callback.</param>
public void FireNotification(string symbolPath, object? value)
{
foreach (var n in Notifications)
@@ -93,9 +142,14 @@ internal class FakeTwinCATClient : ITwinCATClient
// ---- symbol browser fake ----
/// <summary>Gets the simulated browse results.</summary>
public List<TwinCATDiscoveredSymbol> BrowseResults { get; } = new();
/// <summary>Gets or sets a value indicating whether BrowseSymbolsAsync should throw.</summary>
public bool ThrowOnBrowse { get; set; }
/// <summary>Simulates browsing the symbol tree.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async enumerable of discovered symbols.</returns>
public virtual async IAsyncEnumerable<TwinCATDiscoveredSymbol> BrowseSymbolsAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -108,16 +162,23 @@ internal class FakeTwinCATClient : ITwinCATClient
}
}
/// <summary>Represents a registered notification in the fake client.</summary>
public sealed class FakeNotification(
string symbolPath, TwinCATDataType type, int? bitIndex,
Action<string, object?> onChange, FakeTwinCATClient owner) : ITwinCATNotificationHandle
{
/// <summary>Gets the symbol path being watched.</summary>
public string SymbolPath { get; } = symbolPath;
/// <summary>Gets the data type of the symbol.</summary>
public TwinCATDataType Type { get; } = type;
/// <summary>Gets the optional bit index.</summary>
public int? BitIndex { get; } = bitIndex;
/// <summary>Gets the callback to invoke on value change.</summary>
public Action<string, object?> OnChange { get; } = onChange;
/// <summary>Gets a value indicating whether this notification has been disposed.</summary>
public bool Disposed { get; private set; }
/// <summary>Disposes this notification handle.</summary>
public void Dispose()
{
Disposed = true;
@@ -126,11 +187,16 @@ internal class FakeTwinCATClient : ITwinCATClient
}
}
/// <summary>Represents a factory for creating fake TwinCAT clients.</summary>
internal sealed class FakeTwinCATClientFactory : ITwinCATClientFactory
{
/// <summary>Gets the list of clients created by this factory.</summary>
public List<FakeTwinCATClient> Clients { get; } = new();
/// <summary>Gets or sets an optional customization function for creating clients.</summary>
public Func<FakeTwinCATClient>? Customise { get; set; }
/// <summary>Creates a new fake TwinCAT client.</summary>
/// <returns>A newly created client instance.</returns>
public ITwinCATClient Create()
{
var client = Customise?.Invoke() ?? new FakeTwinCATClient();
@@ -7,6 +7,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATAmsAddressTests
{
/// <summary>Verifies that TryParse accepts valid AMS addresses in various formats.</summary>
/// <param name="input">The raw AMS address string to parse.</param>
/// <param name="netId">The expected AMS Net ID after parsing.</param>
/// <param name="port">The expected AMS port after parsing.</param>
[Theory]
[InlineData("ads://5.23.91.23.1.1:851", "5.23.91.23.1.1", 851)]
[InlineData("ads://5.23.91.23.1.1:852", "5.23.91.23.1.1", 852)]
@@ -22,6 +26,8 @@ public sealed class TwinCATAmsAddressTests
parsed.Port.ShouldBe(port);
}
/// <summary>Verifies that TryParse rejects invalid AMS address forms.</summary>
/// <param name="input">The invalid AMS address string that should be rejected.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -41,6 +47,10 @@ public sealed class TwinCATAmsAddressTests
TwinCATAmsAddress.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that ToString produces canonical AMS address strings.</summary>
/// <param name="netId">The AMS Net ID to use.</param>
/// <param name="port">The AMS port to use.</param>
/// <param name="expected">The expected canonical string representation.</param>
[Theory]
[InlineData("5.23.91.23.1.1", 851, "ads://5.23.91.23.1.1")] // default port stripped
[InlineData("5.23.91.23.1.1", 852, "ads://5.23.91.23.1.1:852")]
@@ -49,6 +59,7 @@ public sealed class TwinCATAmsAddressTests
new TwinCATAmsAddress(netId, port).ToString().ShouldBe(expected);
}
/// <summary>Verifies that parsing and stringifying an AMS address is stable.</summary>
[Fact]
public void RoundTrip_is_stable()
{
@@ -11,6 +11,7 @@ public sealed class TwinCATCapabilityTests
{
// ---- ITagDiscovery ----
/// <summary>Verifies that DiscoverAsync emits pre-declared tags.</summary>
[Fact]
public async Task DiscoverAsync_emits_pre_declared_tags()
{
@@ -37,6 +38,7 @@ public sealed class TwinCATCapabilityTests
// ---- ISubscribable ----
/// <summary>Verifies that Subscribe initial poll raises OnDataChange.</summary>
[Fact]
public async Task Subscribe_initial_poll_raises_OnDataChange()
{
@@ -63,6 +65,7 @@ public sealed class TwinCATCapabilityTests
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
/// <summary>Verifies that ShutdownAsync cancels active subscriptions.</summary>
[Fact]
public async Task ShutdownAsync_cancels_active_subscriptions()
{
@@ -93,6 +96,7 @@ public sealed class TwinCATCapabilityTests
// ---- IHostConnectivityProbe ----
/// <summary>Verifies that GetHostStatuses returns entry per device.</summary>
[Fact]
public async Task GetHostStatuses_returns_entry_per_device()
{
@@ -110,6 +114,7 @@ public sealed class TwinCATCapabilityTests
drv.GetHostStatuses().Count.ShouldBe(2);
}
/// <summary>Verifies that Probe transitions to Running on successful probe.</summary>
[Fact]
public async Task Probe_transitions_to_Running_on_successful_probe()
{
@@ -136,6 +141,7 @@ public sealed class TwinCATCapabilityTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that Probe transitions to Stopped on probe failure.</summary>
[Fact]
public async Task Probe_transitions_to_Stopped_on_probe_failure()
{
@@ -162,6 +168,7 @@ public sealed class TwinCATCapabilityTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that Probe is disabled when Enabled is false.</summary>
[Fact]
public async Task Probe_disabled_when_Enabled_is_false()
{
@@ -180,6 +187,7 @@ public sealed class TwinCATCapabilityTests
// ---- IPerCallHostResolver ----
/// <summary>Verifies that ResolveHost returns declared device for known tag.</summary>
[Fact]
public async Task ResolveHost_returns_declared_device_for_known_tag()
{
@@ -203,6 +211,7 @@ public sealed class TwinCATCapabilityTests
drv.ResolveHost("B").ShouldBe("ads://5.23.91.24.1.1:851");
}
/// <summary>Verifies that ResolveHost falls back to first device for unknown reference.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_first_device_for_unknown_ref()
{
@@ -216,6 +225,7 @@ public sealed class TwinCATCapabilityTests
drv.ResolveHost("missing").ShouldBe("ads://5.23.91.23.1.1:851");
}
/// <summary>Verifies that ResolveHost falls back to unresolved sentinel when no devices.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_unresolved_sentinel_when_no_devices()
{
@@ -238,22 +248,44 @@ public sealed class TwinCATCapabilityTests
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of folders added to the address space.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the list of variables added to the address space.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Adds a folder with the specified browse name and display name.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
/// <returns>This builder instance.</returns>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Adds a variable with the specified browse name, display name, and attribute info.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="info">The driver attribute information for the variable.</param>
/// <returns>A variable handle for the added variable.</returns>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>Adds a property to a variable.</summary>
/// <param name="_">The property name.</param>
/// <param name="__">The property data type.</param>
/// <param name="___">The property value.</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference name of the variable.</summary>
public string FullReference => fullRef;
/// <summary>Marks the variable as an alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
/// <returns>An alarm condition sink.</returns>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
private sealed class NullSink : IAlarmConditionSink {
/// <summary>Called when an alarm transitions.</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { } }
}
}
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATDriverTests
{
/// <summary>Verifies that the driver type is correctly identified as TwinCAT.</summary>
[Fact]
public void DriverType_is_TwinCAT()
{
@@ -16,6 +17,7 @@ public sealed class TwinCATDriverTests
drv.DriverInstanceId.ShouldBe("drv-1");
}
/// <summary>Verifies that device addresses are parsed during initialization.</summary>
[Fact]
public async Task InitializeAsync_parses_device_addresses()
{
@@ -35,6 +37,7 @@ public sealed class TwinCATDriverTests
drv.GetDeviceState("ads://10.0.0.1.1.1:852")!.Options.DeviceName.ShouldBe("Machine2");
}
/// <summary>Verifies that malformed device addresses cause initialization to fault.</summary>
[Fact]
public async Task InitializeAsync_malformed_address_faults()
{
@@ -48,6 +51,7 @@ public sealed class TwinCATDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>Verifies that shutdown clears all devices.</summary>
[Fact]
public async Task ShutdownAsync_clears_devices()
{
@@ -63,6 +67,7 @@ public sealed class TwinCATDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Verifies that reinitialization cycles devices.</summary>
[Fact]
public async Task ReinitializeAsync_cycles_devices()
{
@@ -78,6 +83,7 @@ public sealed class TwinCATDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies that data type mapping covers atomic IEC types.</summary>
[Fact]
public void DataType_mapping_covers_atomic_iec_types()
{
@@ -91,6 +97,9 @@ public sealed class TwinCATDriverTests
TwinCATDataType.Time.ToDriverDataType().ShouldBe(DriverDataType.UInt32);
}
/// <summary>Verifies that status mapper covers known ADS error codes.</summary>
/// <param name="adsError">The raw ADS error code to map.</param>
/// <param name="expected">The expected OPC UA status code after mapping.</param>
[Theory]
[InlineData(0u, TwinCATStatusMapper.Good)]
// Device-layer codes — confirmed from Beckhoff.TwinCAT.Ads 7.0.172 AdsErrorCode enum
@@ -19,6 +19,7 @@ public sealed class TwinCATHighFindingsRegressionTests
// ---- Driver.TwinCAT-001 — Reinitialize applies the new config generation ----
/// <summary>Verifies ReinitializeAsync applies new device configuration at runtime.</summary>
[Fact]
public async Task ReinitializeAsync_applies_changed_device_config()
{
@@ -47,6 +48,7 @@ public sealed class TwinCATHighFindingsRegressionTests
drv.GetDeviceState("ads://10.0.0.1.1.1:852")!.Options.DeviceName.ShouldBe("Machine2");
}
/// <summary>Verifies InitializeAsync applies supplied JSON config over constructor options.</summary>
[Fact]
public async Task InitializeAsync_applies_supplied_config_over_constructor_options()
{
@@ -66,6 +68,7 @@ public sealed class TwinCATHighFindingsRegressionTests
// ---- Driver.TwinCAT-002 — 64-bit + unsigned types map without truncation ----
/// <summary>Verifies TwinCAT data types map to driver types without truncation of width or signedness.</summary>
[Fact]
public void DataType_mapping_preserves_width_and_signedness()
{
@@ -79,6 +82,7 @@ public sealed class TwinCATHighFindingsRegressionTests
TwinCATDataType.SInt.ToDriverDataType().ShouldBe(DriverDataType.Int16);
}
/// <summary>Verifies 64-bit LInt reads preserve values larger than int.MaxValue.</summary>
[Fact]
public async Task LInt_read_round_trips_value_above_int_MaxValue()
{
@@ -102,6 +106,7 @@ public sealed class TwinCATHighFindingsRegressionTests
// ---- Driver.TwinCAT-007 — concurrent EnsureConnectedAsync creates exactly one client ----
/// <summary>Verifies concurrent reads on one device create and share exactly one client.</summary>
[Fact]
public async Task Concurrent_reads_on_one_device_create_a_single_client()
{
@@ -124,6 +129,7 @@ public sealed class TwinCATHighFindingsRegressionTests
factory.Clients[0].ConnectCount.ShouldBe(1);
}
/// <summary>Verifies concurrent reads and writes on one device share exactly one client.</summary>
[Fact]
public async Task Concurrent_reads_and_writes_share_one_client()
{
@@ -151,6 +157,7 @@ public sealed class TwinCATHighFindingsRegressionTests
// ---- Driver.TwinCAT-013 — symbol-version-changed routes to IRediscoverable ----
/// <summary>Verifies TwinCATDriver implements the IRediscoverable interface.</summary>
[Fact]
public void TwinCATDriver_implements_IRediscoverable()
{
@@ -158,6 +165,7 @@ public sealed class TwinCATHighFindingsRegressionTests
drv.ShouldBeAssignableTo<IRediscoverable>();
}
/// <summary>Verifies symbol version change events raise the OnRediscoveryNeeded event.</summary>
[Fact]
public async Task Symbol_version_changed_raises_OnRediscoveryNeeded()
{
@@ -184,6 +192,7 @@ public sealed class TwinCATHighFindingsRegressionTests
raised!.Reason.ShouldContain("0x0711");
}
/// <summary>Verifies status mapper recognizes the symbol version changed error code.</summary>
[Fact]
public void StatusMapper_recognises_symbol_version_changed_code()
{
@@ -21,6 +21,7 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-004 — TIME/DATE/DT/TOD surface unchanged but comments corrected ----
/// <summary>Verifies that IEC time types map to uint32 raw counters.</summary>
[Fact]
public void Iec_time_types_map_to_uint32_raw_counter()
{
@@ -35,6 +36,7 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-006 — ResolveHost sentinel when no devices are configured ----
/// <summary>Verifies that ResolveHost returns an unresolved sentinel when no devices are configured.</summary>
[Fact]
public async Task ResolveHost_returns_unresolved_sentinel_when_no_devices()
{
@@ -48,6 +50,7 @@ public sealed class TwinCATLowFindingsRegressionTests
drv.ResolveHost("anything").ShouldBe(string.Empty);
}
/// <summary>Verifies that the unresolved sentinel does not match any GetHostStatuses entry.</summary>
[Fact]
public async Task ResolveHost_unresolved_sentinel_matches_no_GetHostStatuses_entry()
{
@@ -61,6 +64,7 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-014 — config surface knobs are honoured ----
/// <summary>Verifies that ProbeOptions.Timeout is applied to probe calls.</summary>
[Fact]
public async Task ProbeOptions_Timeout_is_applied_to_probe_calls()
{
@@ -90,6 +94,7 @@ public sealed class TwinCATLowFindingsRegressionTests
observed.ShouldContain(TimeSpan.FromMilliseconds(750));
}
/// <summary>Verifies that NotificationMaxDelayMs is exposed on driver options.</summary>
[Fact]
public void NotificationMaxDelayMs_is_exposed_on_driver_options()
{
@@ -100,6 +105,7 @@ public sealed class TwinCATLowFindingsRegressionTests
options.NotificationMaxDelayMs.ShouldBe(200);
}
/// <summary>Verifies that NotificationMaxDelayMs parses from driver configuration JSON.</summary>
[Fact]
public void NotificationMaxDelayMs_parses_from_driver_config_json()
{
@@ -114,6 +120,7 @@ public sealed class TwinCATLowFindingsRegressionTests
// ---- Driver.TwinCAT-015 — Dispose runs a true synchronous teardown ----
/// <summary>Verifies that Dispose does not block on async operations in the default synchronization context.</summary>
[Fact]
public void Dispose_does_not_block_on_async_in_default_synchronization_context()
{
@@ -166,12 +173,16 @@ public sealed class TwinCATLowFindingsRegressionTests
private sealed class SingleThreadedSyncContext : SynchronizationContext
{
private readonly System.Collections.Concurrent.ConcurrentQueue<(SendOrPostCallback cb, object? state)> _queue = new();
/// <inheritdoc />
public override void Post(SendOrPostCallback d, object? state) => _queue.Enqueue((d, state));
/// <inheritdoc />
public override void Send(SendOrPostCallback d, object? state) => d(state);
}
// ---- Driver.TwinCAT-016 — gap-fill tests for previously closed findings ----
/// <summary>Verifies that structure-typed pre-declared tags are rejected at configuration parse time.</summary>
[Fact]
public void Structure_typed_pre_declared_tag_is_rejected_at_config_parse()
{
@@ -197,6 +208,7 @@ public sealed class TwinCATLowFindingsRegressionTests
ex.Message.ShouldContain("Udt1");
}
/// <summary>Verifies that the probe loop and read operations share one client per device.</summary>
[Fact]
public async Task Probe_loop_and_read_share_one_client_per_device()
{
@@ -250,8 +262,12 @@ public sealed class TwinCATLowFindingsRegressionTests
private sealed class ProbeTimeoutCapturingFake : FakeTwinCATClient
{
private readonly List<TimeSpan> _observed;
/// <summary>Initializes a new instance of the ProbeTimeoutCapturingFake class.</summary>
/// <param name="observed">The list to store observed timeout values.</param>
public ProbeTimeoutCapturingFake(List<TimeSpan> observed) { _observed = observed; }
/// <inheritdoc />
public override Task ConnectAsync(TwinCATAmsAddress address, TimeSpan timeout, CancellationToken ct)
{
// The driver calls EnsureConnectedAsync with the probe timeout for probe-initiated
@@ -22,6 +22,7 @@ public sealed class TwinCATNativeNotificationTests
return (drv, factory);
}
/// <summary>Verifies that native subscribe registers one notification per tag.</summary>
[Fact]
public async Task Native_subscribe_registers_one_notification_per_tag()
{
@@ -37,6 +38,7 @@ public sealed class TwinCATNativeNotificationTests
factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.B"], ignoreOrder: true);
}
/// <summary>Verifies that native notification fires OnDataChange with pushed value.</summary>
[Fact]
public async Task Native_notification_fires_OnDataChange_with_pushed_value()
{
@@ -57,6 +59,7 @@ public sealed class TwinCATNativeNotificationTests
events.Last().FullReference.ShouldBe("Speed"); // driver-side reference, not ADS symbol
}
/// <summary>Verifies that native unsubscribe disposes all notifications.</summary>
[Fact]
public async Task Native_unsubscribe_disposes_all_notifications()
{
@@ -72,6 +75,7 @@ public sealed class TwinCATNativeNotificationTests
factory.Clients[0].Notifications.ShouldBeEmpty();
}
/// <summary>Verifies that native unsubscribe halts future notifications.</summary>
[Fact]
public async Task Native_unsubscribe_halts_future_notifications()
{
@@ -94,6 +98,7 @@ public sealed class TwinCATNativeNotificationTests
events.Count.ShouldBe(afterUnsub);
}
/// <summary>Verifies that native subscribe failure mid-registration cleans up partial state.</summary>
[Fact]
public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state()
{
@@ -128,13 +133,18 @@ public sealed class TwinCATNativeNotificationTests
private sealed class FailAfterNAddsFake : FakeTwinCATClient
{
private readonly int _succeedBefore;
/// <summary>Gets the number of times AddNotificationAsync has been called.</summary>
public int AddCallCount { get; private set; }
/// <summary>Initializes a new instance of FailAfterNAddsFake.</summary>
/// <param name="_">Unused parameter for alignment with parent constructor.</param>
/// <param name="succeedBefore">The number of calls to succeed before failing.</param>
public FailAfterNAddsFake(AbTagParamsIrrelevant _, int succeedBefore) : base()
{
_succeedBefore = succeedBefore;
}
/// <inheritdoc />
public override Task<ITwinCATNotificationHandle> AddNotificationAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, TimeSpan cycleTime,
int maxDelayMs, Action<string, object?> onChange, CancellationToken cancellationToken)
@@ -146,6 +156,7 @@ public sealed class TwinCATNativeNotificationTests
}
}
/// <summary>Verifies that native shutdown disposes subscriptions.</summary>
[Fact]
public async Task Native_shutdown_disposes_subscriptions()
{
@@ -160,6 +171,7 @@ public sealed class TwinCATNativeNotificationTests
factory.Clients[0].Notifications.ShouldBeEmpty();
}
/// <summary>Verifies that the poll path still works when UseNativeNotifications is false.</summary>
[Fact]
public async Task Poll_path_still_works_when_UseNativeNotifications_false()
{
@@ -187,6 +199,7 @@ public sealed class TwinCATNativeNotificationTests
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
/// <summary>Verifies that subscribe handle DiagnosticId indicates native vs poll.</summary>
[Fact]
public async Task Subscribe_handle_DiagnosticId_indicates_native_vs_poll()
{
@@ -22,6 +22,7 @@ public sealed class TwinCATReadWriteTests
// ---- Read ----
/// <summary>Verifies that an unknown reference maps to BadNodeIdUnknown status.</summary>
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
@@ -32,6 +33,7 @@ public sealed class TwinCATReadWriteTests
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that a successful DInt read returns Good status and the correct value.</summary>
[Fact]
public async Task Successful_DInt_read_returns_Good_value()
{
@@ -48,6 +50,7 @@ public sealed class TwinCATReadWriteTests
factory.Clients[0].IsConnected.ShouldBeTrue();
}
/// <summary>Verifies that repeated read operations reuse the same connection.</summary>
[Fact]
public async Task Repeat_read_reuses_connection()
{
@@ -65,6 +68,7 @@ public sealed class TwinCATReadWriteTests
factory.Clients[0].ConnectCount.ShouldBe(1);
}
/// <summary>Verifies that ADS read errors are mapped via the status mapper.</summary>
[Fact]
public async Task Read_with_ADS_error_maps_via_status_mapper()
{
@@ -82,6 +86,7 @@ public sealed class TwinCATReadWriteTests
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that read exceptions surface as BadCommunicationError status.</summary>
[Fact]
public async Task Read_exception_surfaces_BadCommunicationError()
{
@@ -95,6 +100,7 @@ public sealed class TwinCATReadWriteTests
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>Verifies that connect failures surface BadCommunicationError and dispose the client.</summary>
[Fact]
public async Task Connect_failure_surfaces_BadCommunicationError_and_disposes_client()
{
@@ -108,6 +114,7 @@ public sealed class TwinCATReadWriteTests
factory.Clients[0].DisposeCount.ShouldBe(1);
}
/// <summary>Verifies that batched read operations preserve the order of results.</summary>
[Fact]
public async Task Batched_reads_preserve_order()
{
@@ -134,6 +141,7 @@ public sealed class TwinCATReadWriteTests
// ---- Write ----
/// <summary>Verifies that non-writable tags are rejected with BadNotWritable status.</summary>
[Fact]
public async Task Non_writable_tag_rejected_with_BadNotWritable()
{
@@ -146,6 +154,7 @@ public sealed class TwinCATReadWriteTests
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
}
/// <summary>Verifies that successful writes log the symbol, type, and value correctly.</summary>
[Fact]
public async Task Successful_write_logs_symbol_type_value()
{
@@ -163,6 +172,7 @@ public sealed class TwinCATReadWriteTests
write.value.ShouldBe(4200);
}
/// <summary>Verifies that ADS write errors are mapped and surfaced correctly.</summary>
[Fact]
public async Task Write_with_ADS_error_surfaces_mapped_status()
{
@@ -181,6 +191,7 @@ public sealed class TwinCATReadWriteTests
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
}
/// <summary>Verifies that write exceptions surface as BadCommunicationError status.</summary>
[Fact]
public async Task Write_exception_surfaces_BadCommunicationError()
{
@@ -194,6 +205,7 @@ public sealed class TwinCATReadWriteTests
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
}
/// <summary>Verifies that batched write operations preserve order across mixed outcomes.</summary>
[Fact]
public async Task Batch_write_preserves_order_across_outcomes()
{
@@ -223,6 +235,7 @@ public sealed class TwinCATReadWriteTests
results[2].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that cancellation tokens propagate correctly during read operations.</summary>
[Fact]
public async Task Cancellation_propagates()
{
@@ -239,6 +252,7 @@ public sealed class TwinCATReadWriteTests
() => drv.ReadAsync(["X"], CancellationToken.None));
}
/// <summary>Verifies that shutdown disposes the client correctly.</summary>
[Fact]
public async Task ShutdownAsync_disposes_client()
{
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATSymbolBrowserTests
{
/// <summary>Verifies that discovery without EnableControllerBrowse only emits predeclared tags.</summary>
[Fact]
public async Task Discovery_without_EnableControllerBrowse_emits_only_predeclared()
{
@@ -36,6 +37,7 @@ public sealed class TwinCATSymbolBrowserTests
builder.Folders.ShouldNotContain(f => f.BrowseName == "Discovered");
}
/// <summary>Verifies that discovery with browse enabled adds controller symbols under Discovered folder.</summary>
[Fact]
public async Task Discovery_with_browse_enabled_adds_controller_symbols_under_Discovered_folder()
{
@@ -65,6 +67,7 @@ public sealed class TwinCATSymbolBrowserTests
builder.Variables.Select(v => v.Info.FullName).ShouldContain("GVL.Setpoint");
}
/// <summary>Verifies that browse filters out system symbols correctly.</summary>
[Fact]
public async Task Browse_filters_system_symbols()
{
@@ -95,6 +98,7 @@ public sealed class TwinCATSymbolBrowserTests
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Real"]);
}
/// <summary>Verifies that browse skips symbols with null datatype.</summary>
[Fact]
public async Task Browse_skips_symbols_with_null_datatype()
{
@@ -122,6 +126,7 @@ public sealed class TwinCATSymbolBrowserTests
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["MAIN.Counter"]);
}
/// <summary>Verifies that read-only symbols surface as ViewOnly.</summary>
[Fact]
public async Task ReadOnly_symbol_surfaces_ViewOnly()
{
@@ -148,6 +153,7 @@ public sealed class TwinCATSymbolBrowserTests
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Verifies that browse failure is non-fatal and predeclared tags still emit.</summary>
[Fact]
public async Task Browse_failure_is_non_fatal_predeclared_still_emits()
{
@@ -170,6 +176,9 @@ public sealed class TwinCATSymbolBrowserTests
builder.Variables.Select(v => v.BrowseName).ShouldContain("Declared");
}
/// <summary>Verifies that system symbol filter matches expected patterns.</summary>
/// <param name="path">The symbol path to test.</param>
/// <param name="expected">Whether the path is expected to be a system symbol.</param>
[Theory]
[InlineData("TwinCAT_SystemInfoVarList._AppInfo", true)]
[InlineData("TwinCAT_RuntimeInfo.Something", true)]
@@ -191,22 +200,68 @@ public sealed class TwinCATSymbolBrowserTests
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>
/// Gets the list of folders that were added to the address space.
/// </summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>
/// Gets the list of variables that were added to the address space.
/// </summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>
/// Adds a folder to the address space builder.
/// </summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
/// <returns>This builder for method chaining.</returns>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>
/// Adds a variable to the address space builder.
/// </summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="info">The driver attribute information for the variable.</param>
/// <returns>A variable handle for further configuration.</returns>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>
/// Adds a property to the address space builder.
/// </summary>
/// <param name="_">The property name (unused).</param>
/// <param name="__">The property data type (unused).</param>
/// <param name="___">The property value (unused).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>
/// Gets the full reference of the variable.
/// </summary>
public string FullReference => fullRef;
/// <summary>
/// Marks the variable as an alarm condition.
/// </summary>
/// <param name="info">The alarm condition information.</param>
/// <returns>An alarm condition sink for event transitions.</returns>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
/// <summary>
/// A null implementation of IAlarmConditionSink for test purposes.
/// </summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <summary>
/// Handles alarm transitions (no-op for tests).
/// </summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATSymbolPathTests
{
/// <summary>Verifies that a single segment global variable parses correctly.</summary>
[Fact]
public void Single_segment_global_variable_parses()
{
@@ -16,6 +17,7 @@ public sealed class TwinCATSymbolPathTests
p.ToAdsSymbolName().ShouldBe("Counter");
}
/// <summary>Verifies that a POU dot-separated variable parses correctly.</summary>
[Fact]
public void POU_dot_variable_parses()
{
@@ -25,6 +27,7 @@ public sealed class TwinCATSymbolPathTests
p.ToAdsSymbolName().ShouldBe("MAIN.bStart");
}
/// <summary>Verifies that a GVL reference parses correctly.</summary>
[Fact]
public void GVL_reference_parses()
{
@@ -34,6 +37,7 @@ public sealed class TwinCATSymbolPathTests
p.ToAdsSymbolName().ShouldBe("GVL.Counter");
}
/// <summary>Verifies that structured member access is split into segments.</summary>
[Fact]
public void Structured_member_access_splits()
{
@@ -42,6 +46,7 @@ public sealed class TwinCATSymbolPathTests
p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Status", "Running"]);
}
/// <summary>Verifies that array subscripts parse correctly.</summary>
[Fact]
public void Array_subscript_parses()
{
@@ -51,6 +56,7 @@ public sealed class TwinCATSymbolPathTests
p.ToAdsSymbolName().ShouldBe("Data[5]");
}
/// <summary>Verifies that multi-dimensional array subscripts parse correctly.</summary>
[Fact]
public void Multi_dim_array_subscript_parses()
{
@@ -59,6 +65,7 @@ public sealed class TwinCATSymbolPathTests
p.Segments.Single().Subscripts.ShouldBe([1, 2]);
}
/// <summary>Verifies that bit access is captured as a bit index.</summary>
[Fact]
public void Bit_access_captured_as_bit_index()
{
@@ -69,6 +76,7 @@ public sealed class TwinCATSymbolPathTests
p.ToAdsSymbolName().ShouldBe("Flags.3");
}
/// <summary>Verifies that bit access works after a member path.</summary>
[Fact]
public void Bit_access_after_member_path()
{
@@ -78,6 +86,7 @@ public sealed class TwinCATSymbolPathTests
p.BitIndex.ShouldBe(7);
}
/// <summary>Verifies that combined scope, member, subscript, and bit access parses correctly.</summary>
[Fact]
public void Combined_scope_member_subscript_bit()
{
@@ -89,6 +98,8 @@ public sealed class TwinCATSymbolPathTests
p.ToAdsSymbolName().ShouldBe("MAIN.Motors[0].Status.5");
}
/// <summary>Verifies that invalid symbol path shapes return null.</summary>
/// <param name="input">The invalid symbol path input to test.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -108,12 +119,14 @@ public sealed class TwinCATSymbolPathTests
TwinCATSymbolPath.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that underscore-prefixed identifiers are accepted.</summary>
[Fact]
public void Underscore_prefix_idents_accepted()
{
TwinCATSymbolPath.TryParse("_internal_var")!.Segments.Single().Name.ShouldBe("_internal_var");
}
/// <summary>Verifies that ToAdsSymbolName roundtrips correctly for various symbol formats.</summary>
[Fact]
public void ToAdsSymbolName_roundtrips()
{