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
@@ -22,6 +22,7 @@ public sealed class AbCipAlarmProjectionTests
new AbCipStructureMember("In", AbCipDataType.DInt),
]);
/// <summary>Verifies that ALMD structure signature is correctly detected as an alarm.</summary>
[Fact]
public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
{
@@ -36,6 +37,7 @@ public sealed class AbCipAlarmProjectionTests
AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
}
/// <summary>Verifies that severity values map correctly to OPC UA alarm severity levels.</summary>
[Fact]
public void Severity_Mapping_Matches_OPC_UA_Convention()
{
@@ -46,6 +48,7 @@ public sealed class AbCipAlarmProjectionTests
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
}
/// <summary>Verifies that disabled alarm projection returns a valid handle but does not poll.</summary>
[Fact]
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
{
@@ -72,6 +75,7 @@ public sealed class AbCipAlarmProjectionTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that enabled alarm projection starts polling and fires raise event on 0-to-1 transition.</summary>
[Fact]
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
{
@@ -115,6 +119,7 @@ public sealed class AbCipAlarmProjectionTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that alarm clear event fires on 1-to-0 transition.</summary>
[Fact]
public async Task Clear_Event_Fires_On_1_to_0_Transition()
{
@@ -155,6 +160,7 @@ public sealed class AbCipAlarmProjectionTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
[Fact]
public async Task Unsubscribe_Stops_The_Poll_Loop()
{
@@ -18,6 +18,7 @@ public sealed class AbCipBoolInDIntRmwTests
// Uses the base FakeAbCipTag's Value + ReadCount + WriteCount.
}
/// <summary>Verifies that bit set reads parent, ORs bit, and writes back.</summary>
[Fact]
public async Task Bit_set_reads_parent_ORs_bit_writes_back()
{
@@ -48,6 +49,7 @@ public sealed class AbCipBoolInDIntRmwTests
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
}
/// <summary>Verifies that bit clear preserves other bits.</summary>
[Fact]
public async Task Bit_clear_preserves_other_bits()
{
@@ -70,6 +72,7 @@ public sealed class AbCipBoolInDIntRmwTests
(updated & ~(1 << 3)).ShouldBe(unchecked((int)0xFFFFFFF7)); // every other bit preserved
}
/// <summary>Verifies that concurrent bit writes to same parent compose correctly.</summary>
[Fact]
public async Task Concurrent_bit_writes_to_same_parent_compose_correctly()
{
@@ -94,6 +97,7 @@ public sealed class AbCipBoolInDIntRmwTests
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0xFF);
}
/// <summary>Verifies that bit writes to different parents each get their own runtime.</summary>
[Fact]
public async Task Bit_writes_to_different_parents_each_get_own_runtime()
{
@@ -120,6 +124,7 @@ public sealed class AbCipBoolInDIntRmwTests
factory.Tags.ShouldContainKey("Motor2.Flags");
}
/// <summary>Verifies that repeat bit writes reuse one parent runtime.</summary>
[Fact]
public async Task Repeat_bit_writes_reuse_one_parent_runtime()
{
@@ -18,6 +18,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-001 — ReinitializeAsync must apply a changed config JSON ----
/// <summary>Tests that InitializeAsync applies devices and tags from the config JSON.</summary>
[Fact]
public async Task InitializeAsync_applies_devices_and_tags_from_the_config_json()
{
@@ -38,6 +39,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Tests that ReinitializeAsync with changed config JSON picks up the new device.</summary>
[Fact]
public async Task ReinitializeAsync_with_a_changed_config_json_picks_up_the_new_device()
{
@@ -60,6 +62,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
drv.GetDeviceState(Device).ShouldBeNull();
}
/// <summary>Tests that InitializeAsync with blank JSON keeps construction-time options.</summary>
[Fact]
public async Task InitializeAsync_with_blank_json_keeps_construction_time_options()
{
@@ -78,6 +81,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-003 — declaration-only whole-UDT grouping is opt-in ----
/// <summary>Tests that whole UDT grouping is off by default so members read per tag.</summary>
[Fact]
public async Task Whole_udt_grouping_is_off_by_default_so_members_read_per_tag()
{
@@ -107,6 +111,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
factory.Tags.ShouldNotContainKey("Motor");
}
/// <summary>Tests that planner forms no groups when declaration-only grouping is disabled.</summary>
[Fact]
public void Planner_forms_no_groups_when_declaration_only_grouping_is_disabled()
{
@@ -131,6 +136,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-008 — ShutdownAsync awaits probe loops; reads are concurrency-safe ----
/// <summary>Tests that ShutdownAsync awaits the probe loop before returning.</summary>
[Fact]
public async Task ShutdownAsync_awaits_the_probe_loop_before_returning()
{
@@ -155,6 +161,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
drv.DeviceCount.ShouldBe(0);
}
/// <summary>Tests that ShutdownAsync is idempotent.</summary>
[Fact]
public async Task ShutdownAsync_is_idempotent()
{
@@ -168,6 +175,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
await Should.NotThrowAsync(() => drv.ShutdownAsync(CancellationToken.None));
}
/// <summary>Tests that concurrent first reads of the same tag do not corrupt the runtime cache.</summary>
[Fact]
public async Task Concurrent_first_reads_of_the_same_tag_do_not_corrupt_the_runtime_cache()
{
@@ -193,6 +201,9 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-004 — LInt/ULInt/UDInt declared type must agree with runtime value type ----
/// <summary>Verifies that AbCipDataType maps large integer types to their correct driver types.</summary>
/// <param name="abType">The AB CIP data type.</param>
/// <param name="expected">The expected driver data type.</param>
[Theory]
[InlineData(AbCipDataType.LInt, DriverDataType.Int64)]
[InlineData(AbCipDataType.ULInt, DriverDataType.UInt64)]
@@ -205,6 +216,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
abType.ToDriverDataType().ShouldBe(expected);
}
/// <summary>Tests that read UDInt tag returns uint value not negative-wrapped int.</summary>
[Fact]
public async Task Read_UDInt_tag_returns_uint_value_not_negative_wrapped_int()
{
@@ -230,6 +242,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-005 — Structure parent not registered; duplicate key check ----
/// <summary>Tests that structure parent tag read returns BadNotSupported not Good null.</summary>
[Fact]
public async Task Structure_parent_tag_read_returns_BadNotSupported_not_Good_null()
{
@@ -259,6 +272,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
results.Single().Value.ShouldBeNull();
}
/// <summary>Tests that InitializeAsync throws on duplicate tag name.</summary>
[Fact]
public void InitializeAsync_throws_on_duplicate_tag_name()
{
@@ -278,6 +292,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult());
}
/// <summary>Tests that InitializeAsync throws when member name collides with independent tag.</summary>
[Fact]
public void InitializeAsync_throws_when_member_name_collides_with_independent_tag()
{
@@ -302,6 +317,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-010 — stale runtime evicted on failure ----
/// <summary>Tests that read failure evicts runtime so next read creates fresh handle.</summary>
[Fact]
public async Task Read_failure_evicts_runtime_so_next_read_creates_fresh_handle()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverDiscoveryTests
{
/// <summary>Verifies that pre-declared tags emit as variables under device folder.</summary>
[Fact]
public async Task PreDeclared_tags_emit_as_variables_under_device_folder()
{
@@ -33,6 +34,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Verifies that device folder display name falls back to host address when not provided.</summary>
[Fact]
public async Task Device_folder_displayname_falls_back_to_host_address()
{
@@ -49,6 +51,7 @@ public sealed class AbCipDriverDiscoveryTests
&& f.DisplayName == "ab://10.0.0.5/1,0");
}
/// <summary>Verifies that pre-declared system tags are filtered out.</summary>
[Fact]
public async Task PreDeclared_system_tags_are_filtered_out()
{
@@ -70,6 +73,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
}
/// <summary>Verifies that tags for mismatched devices are ignored.</summary>
[Fact]
public async Task Tags_for_mismatched_device_are_ignored()
{
@@ -86,6 +90,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.ShouldBeEmpty();
}
/// <summary>Verifies that controller enumeration adds tags under Discovered folder.</summary>
[Fact]
public async Task Controller_enumeration_adds_tags_under_Discovered_folder()
{
@@ -108,6 +113,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex");
}
/// <summary>Verifies that controller enumeration honours system tag hint and filter.</summary>
[Fact]
public async Task Controller_enumeration_honours_system_tag_hint_and_filter()
{
@@ -129,6 +135,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
}
/// <summary>Verifies that controller enumeration ReadOnly flag surfaces ViewOnly classification.</summary>
[Fact]
public async Task Controller_enumeration_ReadOnly_surfaces_ViewOnly_classification()
{
@@ -148,6 +155,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Verifies that controller enumeration receives correct device parameters.</summary>
[Fact]
public async Task Controller_enumeration_receives_correct_device_params()
{
@@ -171,6 +179,7 @@ public sealed class AbCipDriverDiscoveryTests
capturedParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
}
/// <summary>Verifies that default enumerator factory is used when not injected.</summary>
[Fact]
public void Default_enumerator_factory_is_used_when_not_injected()
{
@@ -183,6 +192,9 @@ public sealed class AbCipDriverDiscoveryTests
drv.ShouldNotBeNull();
}
/// <summary>Verifies that system tag filter rejects infrastructure names.</summary>
/// <param name="name">The tag name to test.</param>
/// <param name="expected">The expected result of the filter.</param>
[Theory]
[InlineData("__DEFVAL_X", true)]
[InlineData("__DEFAULT_Y", true)]
@@ -203,6 +215,7 @@ public sealed class AbCipDriverDiscoveryTests
AbCipSystemTagFilter.IsSystemTag(name).ShouldBe(expected);
}
/// <summary>Verifies that template cache roundtrip put and get work correctly.</summary>
[Fact]
public void TemplateCache_roundtrip_put_get()
{
@@ -222,6 +235,7 @@ public sealed class AbCipDriverDiscoveryTests
cache.Count.ShouldBe(0);
}
/// <summary>Verifies that FlushOptionalCachesAsync clears the template cache.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_clears_template_cache()
{
@@ -235,39 +249,69 @@ public sealed class AbCipDriverDiscoveryTests
// ---- helpers ----
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of recorded folders.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the list of recorded variables.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Records a folder node.</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)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Records a variable node.</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 attribute information for the variable.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>Adds a property (no-op in test).</summary>
/// <param name="_">Property name (unused in test).</param>
/// <param name="__">Property data type (unused in test).</param>
/// <param name="___">Property value (unused in test).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
/// <summary>Test variable handle.</summary>
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>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
/// <summary>Null sink for alarm conditions.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <summary>Handles alarm transition (no-op).</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
/// <summary>Fake enumerator factory for testing.</summary>
private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory
{
private readonly AbCipDiscoveredTag[] _tags;
/// <summary>Gets the last captured device parameters.</summary>
public AbCipTagCreateParams? LastDeviceParams { get; private set; }
/// <summary>Initializes a new instance of the FakeEnumeratorFactory.</summary>
/// <param name="tags">The tags to enumerate.</param>
public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
/// <summary>Creates a new fake enumerator.</summary>
public IAbCipTagEnumerator Create() => new FakeEnumerator(this);
/// <summary>Fake tag enumerator for testing.</summary>
private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator
{
/// <summary>Enumerates discovered tags asynchronously.</summary>
/// <param name="deviceParams">The device parameters for enumeration.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -276,6 +320,7 @@ public sealed class AbCipDriverDiscoveryTests
await Task.CompletedTask;
foreach (var t in outer._tags) yield return t;
}
/// <summary>Disposes the enumerator.</summary>
public void Dispose() { }
}
}
@@ -21,6 +21,7 @@ public sealed class AbCipDriverReadTests
return (drv, factory);
}
/// <summary>Verifies that an unknown reference maps to BadNodeIdUnknown status.</summary>
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
@@ -33,6 +34,7 @@ public sealed class AbCipDriverReadTests
snapshots.Single().Value.ShouldBeNull();
}
/// <summary>Verifies that a tag on an unknown device maps to BadNodeIdUnknown status.</summary>
[Fact]
public async Task Tag_on_unknown_device_maps_to_BadNodeIdUnknown()
{
@@ -49,6 +51,7 @@ public sealed class AbCipDriverReadTests
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that a successful DInt read returns Good status with the correct value.</summary>
[Fact]
public async Task Successful_DInt_read_returns_Good_with_value()
{
@@ -67,6 +70,7 @@ public sealed class AbCipDriverReadTests
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(1);
}
/// <summary>Verifies that repeated reads reuse the runtime without reinitializing.</summary>
[Fact]
public async Task Repeat_read_reuses_runtime_without_reinitialise()
{
@@ -83,6 +87,7 @@ public sealed class AbCipDriverReadTests
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
}
/// <summary>Verifies that non-zero libplctag status is mapped via AbCipStatusMapper.</summary>
[Fact]
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
{
@@ -97,6 +102,7 @@ public sealed class AbCipDriverReadTests
snapshots.Single().Value.ShouldBeNull();
}
/// <summary>Verifies that an exception during read surfaces BadCommunicationError status.</summary>
[Fact]
public async Task Exception_during_read_surfaces_BadCommunicationError()
{
@@ -112,6 +118,7 @@ public sealed class AbCipDriverReadTests
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>Verifies that batched reads preserve order and per-tag status.</summary>
[Fact]
public async Task Batched_reads_preserve_order_and_per_tag_status()
{
@@ -136,6 +143,7 @@ public sealed class AbCipDriverReadTests
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
}
/// <summary>Verifies that a successful read marks health as Healthy.</summary>
[Fact]
public async Task Successful_read_marks_health_Healthy()
{
@@ -149,6 +157,7 @@ public sealed class AbCipDriverReadTests
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
}
/// <summary>Verifies that tag creation parameters are built correctly from device and profile.</summary>
[Fact]
public async Task TagCreateParams_are_built_from_device_and_profile()
{
@@ -166,6 +175,7 @@ public sealed class AbCipDriverReadTests
p.TagName.ShouldBe("Program:P.Counter");
}
/// <summary>Verifies that cancellation propagates from read operations.</summary>
[Fact]
public async Task Cancellation_propagates_from_read()
{
@@ -183,6 +193,7 @@ public sealed class AbCipDriverReadTests
() => drv.ReadAsync(["Slow"], cts.Token));
}
/// <summary>Verifies that ShutdownAsync disposes each tag runtime.</summary>
[Fact]
public async Task ShutdownAsync_disposes_each_tag_runtime()
{
@@ -199,6 +210,7 @@ public sealed class AbCipDriverReadTests
factory.Tags["B"].Disposed.ShouldBeTrue();
}
/// <summary>Verifies that initialization failure disposes the tag and surfaces communication error.</summary>
[Fact]
public async Task Initialize_failure_disposes_tag_and_surfaces_communication_error()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverTests
{
/// <summary>Verifies AbCipDriver reports correct driver type and instance ID.</summary>
[Fact]
public void DriverType_is_AbCip()
{
@@ -17,6 +18,7 @@ public sealed class AbCipDriverTests
drv.DriverInstanceId.ShouldBe("drv-1");
}
/// <summary>Verifies InitializeAsync with no devices succeeds and marks driver healthy.</summary>
[Fact]
public async Task InitializeAsync_with_empty_devices_succeeds_and_marks_healthy()
{
@@ -25,6 +27,7 @@ public sealed class AbCipDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies InitializeAsync registers devices with their respective PLC family profiles.</summary>
[Fact]
public async Task InitializeAsync_registers_each_device_with_its_family_profile()
{
@@ -44,6 +47,7 @@ public sealed class AbCipDriverTests
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbCipPlcFamilyProfile.Micro800);
}
/// <summary>Verifies InitializeAsync rejects malformed host addresses and faults the driver.</summary>
[Fact]
public async Task InitializeAsync_with_malformed_host_address_faults()
{
@@ -57,6 +61,7 @@ public sealed class AbCipDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>Verifies ShutdownAsync clears devices and marks driver state unknown.</summary>
[Fact]
public async Task ShutdownAsync_clears_devices_and_marks_unknown()
{
@@ -73,6 +78,7 @@ public sealed class AbCipDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Verifies ReinitializeAsync stops and restarts all devices.</summary>
[Fact]
public async Task ReinitializeAsync_cycles_devices()
{
@@ -88,6 +94,7 @@ public sealed class AbCipDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies PLC family profiles expose expected default configuration values.</summary>
[Fact]
public void Family_profiles_expose_expected_defaults()
{
@@ -103,6 +110,7 @@ public sealed class AbCipDriverTests
AbCipPlcFamilyProfile.GuardLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
}
/// <summary>Verifies AB CIP atomic data types map correctly to driver data types.</summary>
[Fact]
public void AbCipDataType_maps_atomics_to_driver_types()
{
@@ -34,6 +34,7 @@ public sealed class AbCipDriverWholeUdtReadTests
new AbCipStructureMember("Torque", AbCipDataType.Real), // offset 4
]);
/// <summary>Verifies that multiple members of the same UDT trigger only one parent read.</summary>
[Fact]
public async Task Two_members_of_same_udt_trigger_one_parent_read()
{
@@ -53,6 +54,7 @@ public sealed class AbCipDriverWholeUdtReadTests
factory.Tags["Motor"].ReadCount.ShouldBe(1);
}
/// <summary>Verifies that each UDT member is decoded at its correct offset.</summary>
[Fact]
public async Task Each_member_decodes_at_its_own_offset()
{
@@ -78,6 +80,7 @@ public sealed class AbCipDriverWholeUdtReadTests
snapshots[1].Value.ShouldBe(9.5f);
}
/// <summary>Verifies that parent read failure marks all grouped members as Bad.</summary>
[Fact]
public async Task Parent_read_failure_stamps_every_grouped_member_Bad()
{
@@ -97,6 +100,7 @@ public sealed class AbCipDriverWholeUdtReadTests
snapshots[1].Value.ShouldBeNull();
}
/// <summary>Verifies that mixed batches group UDT members and fall back to atomic reads.</summary>
[Fact]
public async Task Mixed_batch_groups_udt_and_falls_back_atomics()
{
@@ -116,6 +120,7 @@ public sealed class AbCipDriverWholeUdtReadTests
factory.Tags["PlainDint"].ReadCount.ShouldBe(1);
}
/// <summary>Verifies that a single UDT member uses the per-tag read path rather than grouping.</summary>
[Fact]
public async Task Single_member_of_Udt_uses_per_tag_read_path()
{
@@ -20,6 +20,7 @@ public sealed class AbCipDriverWriteTests
return (drv, factory);
}
/// <summary>Verifies that unknown reference maps to BadNodeIdUnknown status.</summary>
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
@@ -32,6 +33,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that non-writable tags map to BadNotWritable status.</summary>
[Fact]
public async Task Non_writable_tag_maps_to_BadNotWritable()
{
@@ -45,6 +47,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
}
/// <summary>Verifies that successful DInt writes encode and flush values.</summary>
[Fact]
public async Task Successful_DInt_write_encodes_and_flushes()
{
@@ -60,6 +63,7 @@ public sealed class AbCipDriverWriteTests
factory.Tags["Motor1.Speed"].WriteCount.ShouldBe(1);
}
/// <summary>Verifies that bit-in-DInt writes succeed via read-modify-write.</summary>
[Fact]
public async Task Bit_in_dint_write_now_succeeds_via_RMW()
{
@@ -80,6 +84,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>Verifies that non-zero libplctag status after write maps correctly.</summary>
[Fact]
public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper()
{
@@ -94,6 +99,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
}
/// <summary>Verifies that type mismatch surfaces BadTypeMismatch status.</summary>
[Fact]
public async Task Type_mismatch_surfaces_BadTypeMismatch()
{
@@ -119,6 +125,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
}
/// <summary>Verifies that overflow surfaces BadOutOfRange status.</summary>
[Fact]
public async Task Overflow_surfaces_BadOutOfRange()
{
@@ -136,6 +143,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
}
/// <summary>Verifies that exceptions during write surface BadCommunicationError.</summary>
[Fact]
public async Task Exception_during_write_surfaces_BadCommunicationError()
{
@@ -151,6 +159,7 @@ public sealed class AbCipDriverWriteTests
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>Verifies that batch write preserves order across success and failure.</summary>
[Fact]
public async Task Batch_preserves_order_across_success_and_failure()
{
@@ -182,6 +191,7 @@ public sealed class AbCipDriverWriteTests
results[3].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>Verifies that cancellation propagates from write operations.</summary>
[Fact]
public async Task Cancellation_propagates_from_write()
{
@@ -196,8 +206,10 @@ public sealed class AbCipDriverWriteTests
// ---- test-fake variants that exercise the real type / error handling ----
/// <summary>Test fake that uses real Convert methods to exercise type conversion errors.</summary>
private sealed class RealConvertFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
/// <inheritdoc />
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
switch (type)
@@ -212,6 +224,7 @@ public sealed class AbCipDriverWriteTests
private sealed class ThrowingBoolBitFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
/// <inheritdoc />
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
if (type == AbCipDataType.Bool && bitIndex is not null)
@@ -220,14 +233,18 @@ public sealed class AbCipDriverWriteTests
}
}
/// <summary>Test fake that throws on write to simulate communication errors.</summary>
private sealed class ThrowOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
/// <inheritdoc />
public override Task WriteAsync(CancellationToken ct) =>
Task.FromException(new InvalidOperationException("wire dropped"));
}
/// <summary>Test fake that cancels during write to simulate cancellation.</summary>
private sealed class CancelOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
/// <inheritdoc />
public override Task WriteAsync(CancellationToken ct) =>
Task.FromException(new OperationCanceledException());
}
@@ -10,13 +10,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipFetchUdtShapeTests
{
/// <summary>Test implementation of IAbCipTemplateReader.</summary>
private sealed class FakeTemplateReader : IAbCipTemplateReader
{
/// <summary>Gets or sets the response bytes to return.</summary>
public byte[] Response { get; set; } = [];
/// <summary>Gets the count of read operations.</summary>
public int ReadCount { get; private set; }
/// <summary>Gets a value indicating whether the reader has been disposed.</summary>
public bool Disposed { get; private set; }
/// <summary>Gets the last template ID read.</summary>
public uint LastTemplateId { get; private set; }
/// <summary>Reads the template data for the specified device and template ID.</summary>
/// <param name="deviceParams">The device parameters.</param>
/// <param name="templateInstanceId">The template instance ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the template response bytes.</returns>
public Task<byte[]> ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct)
{
ReadCount++;
@@ -24,14 +37,21 @@ public sealed class AbCipFetchUdtShapeTests
return Task.FromResult(Response);
}
/// <summary>Disposes the reader.</summary>
public void Dispose() => Disposed = true;
}
/// <summary>Test factory for creating fake template readers.</summary>
private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory
{
/// <summary>Gets the list of created readers.</summary>
public List<IAbCipTemplateReader> Readers { get; } = new();
/// <summary>Gets or sets an optional customization function for reader creation.</summary>
public Func<IAbCipTemplateReader>? Customise { get; set; }
/// <summary>Creates a new template reader.</summary>
/// <returns>The created reader.</returns>
public IAbCipTemplateReader Create()
{
var r = Customise?.Invoke() ?? new FakeTemplateReader();
@@ -72,6 +92,7 @@ public sealed class AbCipFetchUdtShapeTests
return (Task<AbCipUdtShape?>)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!;
}
/// <summary>Verifies that FetchUdtShapeAsync decodes a blob and caches the result.</summary>
[Fact]
public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result()
{
@@ -101,6 +122,7 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.Count.ShouldBe(1);
}
/// <summary>Verifies that different template IDs result in separate fetch operations.</summary>
[Fact]
public async Task FetchUdtShapeAsync_different_templateIds_each_fetch()
{
@@ -131,6 +153,7 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.Count.ShouldBe(2);
}
/// <summary>Verifies that FetchUdtShapeAsync returns null for an unknown device.</summary>
[Fact]
public async Task FetchUdtShapeAsync_unknown_device_returns_null()
{
@@ -146,6 +169,7 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.ShouldBeEmpty();
}
/// <summary>Verifies that a decode failure returns null and does not cache the result.</summary>
[Fact]
public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache()
{
@@ -168,6 +192,7 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.Count.ShouldBe(2);
}
/// <summary>Verifies that a reader exception returns null.</summary>
[Fact]
public async Task FetchUdtShapeAsync_reader_exception_returns_null()
{
@@ -185,6 +210,7 @@ public sealed class AbCipFetchUdtShapeTests
shape.ShouldBeNull();
}
/// <summary>Verifies that FlushOptionalCachesAsync empties the template cache.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_empties_template_cache()
{
@@ -212,10 +238,18 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.Count.ShouldBe(2);
}
/// <summary>Test implementation of IAbCipTemplateReader that throws on read.</summary>
private sealed class ThrowingTemplateReader : IAbCipTemplateReader
{
/// <summary>Throws an exception when read is attempted.</summary>
/// <param name="p">The device parameters.</param>
/// <param name="id">The template ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>Never returns; throws instead.</returns>
public Task<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
throw new InvalidOperationException("fake read failure");
/// <summary>Disposes the reader.</summary>
public void Dispose() { }
}
}
@@ -7,6 +7,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipHostAddressTests
{
/// <summary>Verifies that TryParse accepts valid address forms.</summary>
/// <param name="input">The raw URI string to parse.</param>
/// <param name="gateway">The expected gateway host.</param>
/// <param name="port">The expected port number.</param>
/// <param name="cipPath">The expected CIP path.</param>
[Theory]
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
[InlineData("ab://10.0.0.5/1,4", "10.0.0.5", 44818, "1,4")]
@@ -25,6 +30,8 @@ public sealed class AbCipHostAddressTests
parsed.CipPath.ShouldBe(cipPath);
}
/// <summary>Verifies that TryParse rejects invalid address forms.</summary>
/// <param name="input">The invalid or null URI string to test.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -41,6 +48,11 @@ public sealed class AbCipHostAddressTests
AbCipHostAddress.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that ToString canonicalises the address format.</summary>
/// <param name="gateway">The gateway host component.</param>
/// <param name="port">The port number.</param>
/// <param name="path">The CIP path component.</param>
/// <param name="expected">The expected canonical string representation.</param>
[Theory]
[InlineData("10.0.0.5", 44818, "1,0", "ab://10.0.0.5/1,0")]
[InlineData("10.0.0.5", 2222, "1,0", "ab://10.0.0.5:2222/1,0")]
@@ -51,6 +63,7 @@ public sealed class AbCipHostAddressTests
addr.ToString().ShouldBe(expected);
}
/// <summary>Verifies that round-trip parsing and formatting is stable.</summary>
[Fact]
public void RoundTrip_is_stable()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipHostProbeTests
{
/// <summary>Verifies that GetHostStatuses returns one entry per configured device.</summary>
[Fact]
public async Task GetHostStatuses_returns_one_entry_per_device()
{
@@ -29,6 +30,7 @@ public sealed class AbCipHostProbeTests
statuses.ShouldAllBe(s => s.State == HostState.Unknown);
}
/// <summary>Verifies that a successful probe read transitions the host state to Running.</summary>
[Fact]
public async Task Probe_with_successful_read_transitions_to_Running()
{
@@ -55,6 +57,7 @@ public sealed class AbCipHostProbeTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that a failed probe read transitions the host state to Stopped.</summary>
[Fact]
public async Task Probe_with_read_failure_transitions_to_Stopped()
{
@@ -83,6 +86,7 @@ public sealed class AbCipHostProbeTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that the probe is disabled when the Enabled option is false.</summary>
[Fact]
public async Task Probe_disabled_when_Enabled_is_false()
{
@@ -103,6 +107,7 @@ public sealed class AbCipHostProbeTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that the probe is skipped when ProbeTagPath is null.</summary>
[Fact]
public async Task Probe_skipped_when_ProbeTagPath_is_null()
{
@@ -119,6 +124,7 @@ public sealed class AbCipHostProbeTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that the probe loops across multiple devices independently.</summary>
[Fact]
public async Task Probe_loops_across_multiple_devices_independently()
{
@@ -155,6 +161,7 @@ public sealed class AbCipHostProbeTests
// ---- IPerCallHostResolver ----
/// <summary>Verifies that ResolveHost returns the declared device for a known tag.</summary>
[Fact]
public async Task ResolveHost_returns_declared_device_for_known_tag()
{
@@ -178,6 +185,7 @@ public sealed class AbCipHostProbeTests
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
}
/// <summary>Verifies that ResolveHost falls back to the first device for an unknown tag reference.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_first_device_for_unknown_reference()
{
@@ -191,6 +199,7 @@ public sealed class AbCipHostProbeTests
drv.ResolveHost("does-not-exist").ShouldBe("ab://10.0.0.5/1,0");
}
/// <summary>Verifies that ResolveHost falls back to the driver instance ID when no devices are configured.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
{
@@ -200,6 +209,7 @@ public sealed class AbCipHostProbeTests
drv.ResolveHost("anything").ShouldBe("drv-1");
}
/// <summary>Verifies that ResolveHost for a UDT member walks to the synthesized definition.</summary>
[Fact]
public async Task ResolveHost_for_UDT_member_walks_to_synthesised_definition()
{
@@ -19,6 +19,7 @@ public sealed class AbCipLoggingTests
{
private const string Device = "ab://10.0.0.5/1,0";
/// <summary>Verifies that constructor accepts an ILogger.</summary>
[Fact]
public void Constructor_accepts_an_ILogger()
{
@@ -34,6 +35,7 @@ public sealed class AbCipLoggingTests
drv.ShouldNotBeNull();
}
/// <summary>Verifies that ProbeLoop logs when an exception is swallowed.</summary>
[Fact]
public async Task ProbeLoop_logs_when_an_exception_is_swallowed()
{
@@ -76,6 +78,7 @@ public sealed class AbCipLoggingTests
.ShouldBeTrue("at least one log entry should reference the probe loop or surface the swallowed exception");
}
/// <summary>Verifies that ReadFailure logs at warning level.</summary>
[Fact]
public async Task ReadFailure_logs_at_warning_level()
{
@@ -102,6 +105,7 @@ public sealed class AbCipLoggingTests
.ShouldBeTrue("read failure on tag 'Speed' should be logged at warning level or above");
}
/// <summary>Verifies that ReadException logs at warning level.</summary>
[Fact]
public async Task ReadException_logs_at_warning_level()
{
@@ -132,6 +136,7 @@ public sealed class AbCipLoggingTests
.ShouldBeTrue("read transport exception should be logged at warning level with the inner exception attached");
}
/// <summary>Verifies that InitializeAsync warns when probe is enabled but ProbeTagPath is blank.</summary>
[Fact]
public async Task InitializeAsync_warns_when_probe_is_enabled_but_ProbeTagPath_is_blank()
{
@@ -158,6 +163,7 @@ public sealed class AbCipLoggingTests
.ShouldBeTrue("probe-enabled-but-inert configuration should be logged at warning level");
}
/// <summary>Verifies that InitializeAsync does not warn when probe is disabled.</summary>
[Fact]
public async Task InitializeAsync_does_not_warn_when_probe_is_disabled()
{
@@ -176,12 +182,26 @@ public sealed class AbCipLoggingTests
.ShouldBeFalse("no probe warning expected when Probe.Enabled is false");
}
/// <summary>Test logger that captures all log entries.</summary>
internal sealed class CapturingLogger<T> : ILogger<T>
{
/// <summary>Gets the captured log entries.</summary>
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
/// <summary>Begins a scope (stub implementation).</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state.</param>
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <summary>Checks if logging is enabled (always true).</summary>
/// <param name="logLevel">The log level to check.</param>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Logs an entry and captures it.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event ID.</param>
/// <param name="state">The log state.</param>
/// <param name="exception">The exception, if any.</param>
/// <param name="formatter">The log message formatter.</param>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
@@ -190,7 +210,9 @@ public sealed class AbCipLoggingTests
private sealed class NullScope : IDisposable
{
/// <summary>Gets the singleton instance.</summary>
public static NullScope Instance { get; } = new();
/// <summary>Disposes the scope (stub implementation).</summary>
public void Dispose() { }
}
}
@@ -20,6 +20,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
{
private const string Device = "ab://10.0.0.5/1,0";
/// <summary>Verifies that per-device AllowPacking override is forwarded to tag creation parameters.</summary>
[Fact]
public async Task Device_AllowPacking_override_is_forwarded_to_tag_create_params()
{
@@ -38,6 +39,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.AllowPacking.ShouldBeFalse();
}
/// <summary>Verifies that AllowPacking defaults inherit from the family profile when not overridden.</summary>
[Fact]
public async Task Device_AllowPacking_default_inherits_from_family_profile()
{
@@ -58,6 +60,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.AllowPacking.ShouldBeTrue();
}
/// <summary>Verifies that Micro800 devices have AllowPacking defaulting to false from the family profile.</summary>
[Fact]
public async Task Micro800_default_AllowPacking_is_false_from_family_profile()
{
@@ -77,6 +80,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["X"].CreationParams.AllowPacking.ShouldBeFalse();
}
/// <summary>Verifies that per-device ConnectionSize override is forwarded to tag creation parameters.</summary>
[Fact]
public async Task Device_ConnectionSize_override_is_forwarded_to_tag_create_params()
{
@@ -94,6 +98,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(504);
}
/// <summary>Verifies that ConnectionSize defaults inherit from the family profile when not overridden.</summary>
[Fact]
public async Task Device_ConnectionSize_default_inherits_from_family_profile()
{
@@ -112,6 +117,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(4002);
}
/// <summary>Verifies that AllowPacking and ConnectionSize round-trip correctly through JSON parsing.</summary>
[Fact]
public void AbCipDriverFactoryExtensions_ParseOptions_round_trips_AllowPacking_and_ConnectionSize()
{
@@ -11,6 +11,7 @@ public sealed class AbCipPlcFamilyTests
{
// ---- ControlLogix ----
/// <summary>Verifies that the ControlLogix profile defaults match the large forward open baseline.</summary>
[Fact]
public void ControlLogix_profile_defaults_match_large_forward_open_baseline()
{
@@ -23,6 +24,7 @@ public sealed class AbCipPlcFamilyTests
p.MaxFragmentBytes.ShouldBe(4000);
}
/// <summary>Verifies that a ControlLogix device initializes with the correct profile.</summary>
[Fact]
public async Task ControlLogix_device_initialises_with_correct_profile()
{
@@ -38,6 +40,7 @@ public sealed class AbCipPlcFamilyTests
// ---- CompactLogix ----
/// <summary>Verifies that the CompactLogix profile uses a narrower connection size than ControlLogix.</summary>
[Fact]
public void CompactLogix_profile_uses_narrower_connection_size()
{
@@ -50,6 +53,7 @@ public sealed class AbCipPlcFamilyTests
p.MaxFragmentBytes.ShouldBe(500);
}
/// <summary>Verifies that a CompactLogix device initializes with a narrow connection size.</summary>
[Fact]
public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize()
{
@@ -67,6 +71,7 @@ public sealed class AbCipPlcFamilyTests
// ---- Micro800 ----
/// <summary>Verifies that the Micro800 profile is unconnected only and supports an empty CIP path.</summary>
[Fact]
public void Micro800_profile_is_unconnected_only_with_empty_path()
{
@@ -79,6 +84,7 @@ public sealed class AbCipPlcFamilyTests
p.MaxFragmentBytes.ShouldBe(484);
}
/// <summary>Verifies that a Micro800 device with an empty CIP path parses correctly.</summary>
[Fact]
public async Task Micro800_device_with_empty_cip_path_parses_correctly()
{
@@ -95,6 +101,7 @@ public sealed class AbCipPlcFamilyTests
state.Profile.SupportsConnectedMessaging.ShouldBeFalse();
}
/// <summary>Verifies that Micro800 read operations forward the empty path to tag creation parameters.</summary>
[Fact]
public async Task Micro800_read_forwards_empty_path_to_tag_create_params()
{
@@ -114,6 +121,7 @@ public sealed class AbCipPlcFamilyTests
// ---- GuardLogix ----
/// <summary>Verifies that the GuardLogix profile wire protocol mirrors ControlLogix.</summary>
[Fact]
public void GuardLogix_profile_wire_protocol_mirrors_ControlLogix()
{
@@ -125,6 +133,7 @@ public sealed class AbCipPlcFamilyTests
p.DefaultCipPath.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath);
}
/// <summary>Verifies that GuardLogix safety tags surface as ViewOnly in discovery.</summary>
[Fact]
public async Task GuardLogix_safety_tag_surfaces_as_ViewOnly_in_discovery()
{
@@ -150,6 +159,7 @@ public sealed class AbCipPlcFamilyTests
.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Verifies that GuardLogix safety tag writes are rejected even when the tag is marked Writable.</summary>
[Fact]
public async Task GuardLogix_safety_tag_writes_rejected_even_when_Writable_is_true()
{
@@ -174,6 +184,9 @@ public sealed class AbCipPlcFamilyTests
// ---- ForFamily dispatch ----
/// <summary>Verifies that ForFamily dispatches to the correct profile for each PLC family.</summary>
/// <param name="family">The AB CIP PLC family to test.</param>
/// <param name="expectedAttribute">The expected libplctag PLC attribute string.</param>
[Theory]
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
@@ -188,22 +201,43 @@ public sealed class AbCipPlcFamilyTests
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of folders recorded by this builder.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the list of variables recorded by this builder.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Adds a folder to the recorded list 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)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Adds a variable to the recorded 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="info">The driver attribute information.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>No-op property adding operation for test compatibility.</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 for this variable handle.</summary>
public string FullReference => fullRef;
/// <summary>Marks this variable as an alarm condition and returns a null sink.</summary>
/// <param name="info">The alarm condition information.</param>
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 state transitions.</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -8,6 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipStatusMapperTests
{
/// <summary>Verifies that CIP general status codes are mapped to OPC UA status codes.</summary>
/// <param name="status">The raw CIP general status byte.</param>
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData((byte)0x00, AbCipStatusMapper.Good)]
[InlineData((byte)0x04, AbCipStatusMapper.BadNodeIdUnknown)]
@@ -29,6 +32,9 @@ public sealed class AbCipStatusMapperTests
// Driver.AbCip-002 — the integers here are the underlying values of the libplctag.NET
// Status enum (what (int)Tag.GetStatus() actually returns), NOT raw native PLCTAG_ERR_*
// constants. The libplctag.NET wrapper renumbers the native codes into a contiguous enum.
/// <summary>Verifies that libplctag Status enum values are mapped to OPC UA status codes.</summary>
/// <param name="status">The libplctag Status enum value to map.</param>
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData(Status.Ok, AbCipStatusMapper.Good)]
[InlineData(Status.Pending, AbCipStatusMapper.GoodMoreData)]
@@ -47,6 +53,7 @@ public sealed class AbCipStatusMapperTests
AbCipStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected);
}
/// <summary>Verifies that timeout is distinguished from generic communication error.</summary>
[Fact]
public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error()
{
@@ -20,6 +20,7 @@ public sealed class AbCipSubscriptionTests
return (drv, factory);
}
/// <summary>Verifies that the initial poll raises OnDataChange events for every subscribed tag.</summary>
[Fact]
public async Task Initial_poll_raises_OnDataChange_for_every_tag()
{
@@ -45,6 +46,7 @@ public sealed class AbCipSubscriptionTests
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
/// <summary>Verifies that unchanged values raise OnDataChange only once (on initial poll).</summary>
[Fact]
public async Task Unchanged_value_raises_only_once()
{
@@ -63,6 +65,7 @@ public sealed class AbCipSubscriptionTests
events.Count.ShouldBe(1);
}
/// <summary>Verifies that value changes between polls raise OnDataChange events.</summary>
[Fact]
public async Task Value_change_between_polls_raises_OnDataChange()
{
@@ -85,6 +88,7 @@ public sealed class AbCipSubscriptionTests
events.Last().Snapshot.Value.ShouldBe(200);
}
/// <summary>Verifies that unsubscribe halts polling and no further events are raised.</summary>
[Fact]
public async Task Unsubscribe_halts_polling()
{
@@ -107,6 +111,7 @@ public sealed class AbCipSubscriptionTests
events.Count.ShouldBe(afterUnsub);
}
/// <summary>Verifies that polling intervals below 100ms are floored to the minimum.</summary>
[Fact]
public async Task Interval_below_100ms_is_floored()
{
@@ -127,6 +132,7 @@ public sealed class AbCipSubscriptionTests
events.Count.ShouldBe(1);
}
/// <summary>Verifies that ShutdownAsync cancels all active subscriptions.</summary>
[Fact]
public async Task ShutdownAsync_cancels_active_subscriptions()
{
@@ -147,6 +153,7 @@ public sealed class AbCipSubscriptionTests
events.Count.ShouldBe(afterShutdown);
}
/// <summary>Verifies that subscriptions on UDT members use the synthesized full reference.</summary>
[Fact]
public async Task Subscription_on_UDT_member_uses_synthesised_full_reference()
{
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipTagPathTests
{
/// <summary>Verifies that a controller-scope single-segment tag path parses correctly.</summary>
[Fact]
public void Controller_scope_single_segment()
{
@@ -20,6 +21,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Motor1_Speed");
}
/// <summary>Verifies that a program-scope tag path parses correctly.</summary>
[Fact]
public void Program_scope_parses()
{
@@ -30,6 +32,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Program:MainProgram.StepIndex");
}
/// <summary>Verifies that structured member access splits into multiple segments.</summary>
[Fact]
public void Structured_member_access_splits_segments()
{
@@ -39,6 +42,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Motor1.Speed.Setpoint");
}
/// <summary>Verifies that single-dimensional array subscript is parsed correctly.</summary>
[Fact]
public void Single_dim_array_subscript()
{
@@ -49,6 +53,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Data[7]");
}
/// <summary>Verifies that multi-dimensional array subscript is parsed correctly.</summary>
[Fact]
public void Multi_dim_array_subscript()
{
@@ -58,6 +63,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Matrix[1,2,3]");
}
/// <summary>Verifies that bit index in DINT is captured correctly.</summary>
[Fact]
public void Bit_in_dint_captured_as_bit_index()
{
@@ -68,6 +74,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Flags.3");
}
/// <summary>Verifies that bit index after member access is captured correctly.</summary>
[Fact]
public void Bit_in_dint_after_member()
{
@@ -78,6 +85,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Motor.Status.12");
}
/// <summary>Verifies that bit index 32 is rejected as out of range.</summary>
[Fact]
public void Bit_index_32_rejected_out_of_range()
{
@@ -86,6 +94,7 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse("Flags.32").ShouldBeNull();
}
/// <summary>Verifies that program scope with members, subscript, and bit index parses correctly.</summary>
[Fact]
public void Program_scope_with_members_and_subscript_and_bit()
{
@@ -98,6 +107,8 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors[0].Status.5");
}
/// <summary>Verifies that invalid tag path shapes return null.</summary>
/// <param name="input">The input string to test for invalid shapes.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -117,12 +128,14 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that identifiers with underscores are accepted.</summary>
[Fact]
public void Ident_with_underscore_accepted()
{
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
}
/// <summary>Verifies that ToLibplctagName recomposes a round-trip correctly.</summary>
[Fact]
public void ToLibplctagName_recomposes_round_trip()
{
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtMemberLayoutTests
{
/// <summary>Verifies that packed atomic types get natural alignment offsets.</summary>
[Fact]
public void Packed_Atomics_Get_Natural_Alignment_Offsets()
{
@@ -27,6 +28,7 @@ public sealed class AbCipUdtMemberLayoutTests
offsets["D"].ShouldBe(16);
}
/// <summary>Verifies that signed integer types are packed without padding.</summary>
[Fact]
public void SInt_Packed_Without_Padding()
{
@@ -42,6 +44,7 @@ public sealed class AbCipUdtMemberLayoutTests
offsets["Z"].ShouldBe(2);
}
/// <summary>Verifies that layout returns null when a member is a Bool type.</summary>
[Fact]
public void Returns_Null_When_Member_Is_Bool()
{
@@ -55,6 +58,7 @@ public sealed class AbCipUdtMemberLayoutTests
AbCipUdtMemberLayout.TryBuild(members).ShouldBeNull();
}
/// <summary>Verifies that layout returns null when a member is a String or Structure type.</summary>
[Fact]
public void Returns_Null_When_Member_Is_String_Or_Structure()
{
@@ -64,6 +68,7 @@ public sealed class AbCipUdtMemberLayoutTests
new[] { new AbCipStructureMember("Nested", AbCipDataType.Structure) }).ShouldBeNull();
}
/// <summary>Verifies that layout returns null when the member list is empty.</summary>
[Fact]
public void Returns_Null_On_Empty_Members()
{
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtMemberTests
{
/// <summary>Verifies that UDT with declared members expands to individual member variables.</summary>
[Fact]
public async Task UDT_with_declared_members_fans_out_to_member_variables()
{
@@ -46,6 +47,7 @@ public sealed class AbCipUdtMemberTests
.ShouldBeTrue();
}
/// <summary>Verifies that UDT members can be read via synthesised full reference paths.</summary>
[Fact]
public async Task UDT_members_resolvable_for_read_via_synthesised_full_reference()
{
@@ -81,6 +83,7 @@ public sealed class AbCipUdtMemberTests
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>Verifies that UDT member writes route through synthesised tag paths.</summary>
[Fact]
public async Task UDT_member_write_routes_through_synthesised_tagpath()
{
@@ -106,6 +109,7 @@ public sealed class AbCipUdtMemberTests
factory.Tags["Motor1.SetPoint"].Value.ShouldBe(42.5f);
}
/// <summary>Verifies that UDT member read/write operations respect the Writable flag.</summary>
[Fact]
public async Task UDT_member_read_write_honours_member_Writable_flag()
{
@@ -130,6 +134,7 @@ public sealed class AbCipUdtMemberTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
}
/// <summary>Verifies that structure tags without declared members appear as single variables.</summary>
[Fact]
public async Task Structure_tag_without_members_is_emitted_as_single_variable()
{
@@ -150,6 +155,7 @@ public sealed class AbCipUdtMemberTests
builder.Folders.ShouldNotContain(f => f.BrowseName == "OpaqueUdt");
}
/// <summary>Verifies that empty member lists are treated the same as null.</summary>
[Fact]
public async Task Empty_Members_list_is_treated_like_null()
{
@@ -167,6 +173,7 @@ public sealed class AbCipUdtMemberTests
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
}
/// <summary>Verifies that UDT members and flat tags can coexist in the address space.</summary>
[Fact]
public async Task UDT_members_mixed_with_flat_tags_coexist()
{
@@ -194,24 +201,48 @@ public sealed class AbCipUdtMemberTests
// ---- helpers ----
/// <summary>Recording builder for testing address space construction.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the collected folders.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the collected variables.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Records a folder in the address space.</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)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Records a variable in the address space.</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>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>Records a property (stub implementation for testing).</summary>
/// <param name="_">The property name (unused in this stub).</param>
/// <param name="__">The property data type (unused in this stub).</param>
/// <param name="___">The property value (unused in this stub).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
/// <summary>Variable handle implementation for testing.</summary>
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference path.</summary>
public string FullReference => fullRef;
/// <summary>Marks this handle as an alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
/// <summary>Null alarm condition sink for testing.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <summary>Handles alarm transitions (stub).</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -8,6 +8,7 @@ public sealed class AbCipUdtReadPlannerTests
{
private const string Device = "ab://10.0.0.1/1,0";
/// <summary>Verifies two members of the same UDT parent are grouped.</summary>
[Fact]
public void Groups_Two_Members_Of_The_Same_Udt_Parent()
{
@@ -20,6 +21,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks.Count.ShouldBe(0);
}
/// <summary>Verifies single member reference falls back to per-tag path.</summary>
[Fact]
public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path()
{
@@ -33,6 +35,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
}
/// <summary>Verifies unknown references fall back without affecting groups.</summary>
[Fact]
public void Unknown_References_Fall_Back_Without_Affecting_Groups()
{
@@ -47,6 +50,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember");
}
/// <summary>Verifies atomic top-level tags fall back untouched.</summary>
[Fact]
public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
{
@@ -62,6 +66,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
}
/// <summary>Verifies UDT with bool member does not group.</summary>
[Fact]
public void Udt_With_Bool_Member_Does_Not_Group()
{
@@ -88,6 +93,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks.Count.ShouldBe(2);
}
/// <summary>Verifies original indices are preserved for out-of-order batches.</summary>
[Fact]
public void Original_Indices_Preserved_For_Out_Of_Order_Batches()
{
@@ -50,6 +50,7 @@ public sealed class CipSymbolObjectDecoderTests
return result;
}
/// <summary>Verifies that a single DInt entry decodes correctly.</summary>
[Fact]
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
{
@@ -69,6 +70,9 @@ public sealed class CipSymbolObjectDecoderTests
tags[0].IsSystemTag.ShouldBeFalse();
}
/// <summary>Verifies that all known atomic type codes map to correct data types.</summary>
/// <param name="typeCode">The CIP type code to map.</param>
/// <param name="expected">The expected AbCipDataType result.</param>
[Theory]
[InlineData((byte)0xC1, AbCipDataType.Bool)]
[InlineData((byte)0xC2, AbCipDataType.SInt)]
@@ -87,12 +91,14 @@ public sealed class CipSymbolObjectDecoderTests
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
}
/// <summary>Verifies that unknown type codes return null for opaque handling.</summary>
[Fact]
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
{
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
}
/// <summary>Verifies that struct flag overrides type code.</summary>
[Fact]
public void Struct_flag_overrides_type_code_and_yields_Structure()
{
@@ -108,6 +114,7 @@ public sealed class CipSymbolObjectDecoderTests
tag.DataType.ShouldBe(AbCipDataType.Structure);
}
/// <summary>Verifies that system flag surfaces as IsSystemTag true.</summary>
[Fact]
public void System_flag_surfaces_as_IsSystemTag_true()
{
@@ -123,6 +130,7 @@ public sealed class CipSymbolObjectDecoderTests
tag.DataType.ShouldBe(AbCipDataType.DInt);
}
/// <summary>Verifies that program scope names split correctly into prefix and name.</summary>
[Fact]
public void Program_scope_name_splits_prefix_into_ProgramScope()
{
@@ -138,6 +146,7 @@ public sealed class CipSymbolObjectDecoderTests
tag.Name.ShouldBe("StepIndex");
}
/// <summary>Verifies that multiple entries decode in wire order with proper padding.</summary>
[Fact]
public void Multiple_entries_decode_in_wire_order_with_even_padding()
{
@@ -154,6 +163,7 @@ public sealed class CipSymbolObjectDecoderTests
tags[1].DataType.ShouldBe(AbCipDataType.Real);
}
/// <summary>Verifies that truncated buffers stop decoding gracefully.</summary>
[Fact]
public void Truncated_buffer_stops_decoding_gracefully()
{
@@ -164,12 +174,17 @@ public sealed class CipSymbolObjectDecoderTests
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
}
/// <summary>Verifies that empty buffers yield no tags.</summary>
[Fact]
public void Empty_buffer_yields_no_tags()
{
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
}
/// <summary>Verifies that SplitProgramScope handles all valid name shapes.</summary>
/// <param name="input">The name string to split.</param>
/// <param name="expectedScope">The expected program scope prefix, if any.</param>
/// <param name="expectedName">The expected name after splitting.</param>
[Theory]
[InlineData("Counter", null, "Counter")]
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
@@ -50,6 +50,7 @@ public sealed class CipTemplateObjectDecoderTests
return buf;
}
/// <summary>Verifies that a simple UDT with two members decodes correctly.</summary>
[Fact]
public void Simple_two_member_UDT_decodes_correctly()
{
@@ -72,6 +73,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members[1].Offset.ShouldBe(4);
}
/// <summary>Verifies that the struct member flag is decoded as Structure type.</summary>
[Fact]
public void Struct_member_flag_surfaces_Structure_type()
{
@@ -84,6 +86,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
/// <summary>Verifies that array members carry correct non-one array lengths.</summary>
[Fact]
public void Array_member_carries_non_one_ArrayLength()
{
@@ -95,6 +98,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members.Single().ArrayLength.ShouldBe(10);
}
/// <summary>Verifies that multiple atomic types preserve their offsets and type information.</summary>
[Fact]
public void Multiple_atomic_types_preserve_offsets_and_types()
{
@@ -116,6 +120,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members.Select(m => m.Offset).ShouldBe([0, 1, 2, 4, 8, 16]);
}
/// <summary>Verifies that unknown atomic type codes fall back to Structure type.</summary>
[Fact]
public void Unknown_atomic_type_code_falls_back_to_Structure()
{
@@ -127,6 +132,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
/// <summary>Verifies that zero member count returns null.</summary>
[Fact]
public void Zero_member_count_returns_null()
{
@@ -135,12 +141,14 @@ public sealed class CipTemplateObjectDecoderTests
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
}
/// <summary>Verifies that a short buffer returns null.</summary>
[Fact]
public void Short_buffer_returns_null()
{
CipTemplateObjectDecoder.Decode([0x01, 0x00]).ShouldBeNull(); // only 2 bytes — less than header
}
/// <summary>Verifies that missing member names surface a placeholder.</summary>
[Fact]
public void Missing_member_name_surfaces_placeholder()
{
@@ -165,6 +173,9 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members[2].Name.ShouldBe("<member_2>");
}
/// <summary>Verifies that semicolon-terminated string parsing handles various input shapes.</summary>
/// <param name="input">The raw semicolon-terminated string input.</param>
/// <param name="expected">The expected array of parsed strings.</param>
[Theory]
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
@@ -10,19 +10,33 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// </summary>
internal class FakeAbCipTag : IAbCipTagRuntime
{
/// <summary>Gets the tag creation parameters.</summary>
public AbCipTagCreateParams CreationParams { get; }
/// <summary>Gets or sets the mock tag value.</summary>
public object? Value { get; set; }
/// <summary>Gets or sets the simulated libplctag status code.</summary>
public int Status { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on <see cref="InitializeAsync"/>.</summary>
public bool ThrowOnInitialize { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on <see cref="ReadAsync"/>.</summary>
public bool ThrowOnRead { get; set; }
/// <summary>Gets or sets the exception to throw when simulation flags are set.</summary>
public Exception? Exception { get; set; }
/// <summary>Gets the count of <see cref="InitializeAsync"/> invocations.</summary>
public int InitializeCount { get; private set; }
/// <summary>Gets the count of <see cref="ReadAsync"/> invocations.</summary>
public int ReadCount { get; private set; }
/// <summary>Gets the count of <see cref="WriteAsync"/> invocations.</summary>
public int WriteCount { get; private set; }
/// <summary>Gets a value indicating whether the tag has been disposed.</summary>
public bool Disposed { get; private set; }
/// <summary>Initializes a new instance of the <see cref="FakeAbCipTag"/> class.</summary>
/// <param name="createParams">The tag creation parameters.</param>
public FakeAbCipTag(AbCipTagCreateParams createParams) => CreationParams = createParams;
/// <summary>Increments the initialize count and simulates initialization.</summary>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public virtual Task InitializeAsync(CancellationToken cancellationToken)
{
InitializeCount++;
@@ -30,6 +44,8 @@ internal class FakeAbCipTag : IAbCipTagRuntime
return Task.CompletedTask;
}
/// <summary>Increments the read count and simulates a read operation.</summary>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public virtual Task ReadAsync(CancellationToken cancellationToken)
{
ReadCount++;
@@ -37,14 +53,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime
return Task.CompletedTask;
}
/// <summary>Increments the write count and simulates a write operation.</summary>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public virtual Task WriteAsync(CancellationToken cancellationToken)
{
WriteCount++;
return Task.CompletedTask;
}
/// <summary>Returns the simulated status code.</summary>
public virtual int GetStatus() => Status;
/// <summary>Returns the mock tag value.</summary>
/// <param name="type">The data type being decoded.</param>
/// <param name="bitIndex">The optional bit index for bit operations.</param>
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
/// <summary>
@@ -56,23 +78,36 @@ internal class FakeAbCipTag : IAbCipTagRuntime
/// </summary>
public Dictionary<int, object?> ValuesByOffset { get; } = new();
/// <summary>Returns the mock value at the specified offset.</summary>
/// <param name="type">The data type being decoded.</param>
/// <param name="offset">The byte offset into the tag storage.</param>
/// <param name="bitIndex">The optional bit index for bit operations.</param>
public virtual object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex)
{
if (ValuesByOffset.TryGetValue(offset, out var v)) return v;
return offset == 0 ? Value : null;
}
/// <summary>Encodes a value into the mock tag storage.</summary>
/// <param name="type">The data type being encoded.</param>
/// <param name="bitIndex">The optional bit index for bit operations.</param>
/// <param name="value">The value to encode.</param>
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
/// <summary>Marks the tag as disposed.</summary>
public virtual void Dispose() => Disposed = true;
}
/// <summary>Test factory that produces <see cref="FakeAbCipTag"/>s and indexes them for assertion.</summary>
internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
{
/// <summary>Gets a dictionary of created tags indexed by tag name for assertion.</summary>
public Dictionary<string, FakeAbCipTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets or sets an optional customization function to override the tag creation.</summary>
public Func<AbCipTagCreateParams, FakeAbCipTag>? Customise { get; set; }
/// <summary>Creates a new fake tag and indexes it by name.</summary>
/// <param name="createParams">The tag creation parameters.</param>
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams)
{
var fake = Customise?.Invoke(createParams) ?? new FakeAbCipTag(createParams);