chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)

Group all 69 projects into category subfolders under src/ and tests/ so the
Rider Solution Explorer mirrors the module structure. Folders: Core, Server,
Drivers (with a nested Driver CLIs subfolder), Client, Tooling.

- Move every project folder on disk with git mv (history preserved as renames).
- Recompute relative paths in 57 .csproj files: cross-category ProjectReferences,
  the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external
  mxaccessgw refs in Driver.Galaxy and its test project.
- Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders.
- Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL,
  integration, install).

Build green (0 errors); unit tests pass. Docs left for a separate pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -0,0 +1,190 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task #177 — tests covering ALMD projection detection, feature-flag gate,
/// subscribe/unsubscribe lifecycle, state-transition event emission, and acknowledge.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipAlarmProjectionTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static AbCipTagDefinition AlmdTag(string name) => new(
name, Device, name, AbCipDataType.Structure, Members:
[
new AbCipStructureMember("InFaulted", AbCipDataType.DInt), // Logix stores ALMD bools as DINT
new AbCipStructureMember("Acked", AbCipDataType.DInt),
new AbCipStructureMember("Severity", AbCipDataType.DInt),
new AbCipStructureMember("In", AbCipDataType.DInt),
]);
[Fact]
public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
{
var almd = AlmdTag("HighTemp");
AbCipAlarmDetector.IsAlmd(almd).ShouldBeTrue();
var plainUdt = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.Structure, Members:
[new AbCipStructureMember("X", AbCipDataType.DInt)]);
AbCipAlarmDetector.IsAlmd(plainUdt).ShouldBeFalse();
var atomic = new AbCipTagDefinition("Plain", Device, "Plain", AbCipDataType.DInt);
AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
}
[Fact]
public void Severity_Mapping_Matches_OPC_UA_Convention()
{
// Logix severity 11000 — mirror the OpcUaClient ACAndC bucketing.
AbCipAlarmProjection.MapSeverity(100).ShouldBe(AlarmSeverity.Low);
AbCipAlarmProjection.MapSeverity(400).ShouldBe(AlarmSeverity.Medium);
AbCipAlarmProjection.MapSeverity(600).ShouldBe(AlarmSeverity.High);
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
}
[Fact]
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = false, // explicit; also the default
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
handle.ShouldNotBeNull();
handle.DiagnosticId.ShouldContain("abcip-alarm-sub-");
// Wait a touch — if polling were active, a fake member-read would be triggered.
await Task.Delay(100);
factory.Tags.ShouldNotContainKey("HighTemp.InFaulted");
factory.Tags.ShouldNotContainKey("HighTemp.Severity");
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
// The ALMD UDT is declared so whole-UDT grouping kicks in; the parent HighTemp runtime
// gets created + polled. Set InFaulted offset-value to 0 first (clear), wait a tick,
// then flip to 1 (fault) + wait for the raise event.
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 0; // InFaulted=false at offset 0
factory.Tags["HighTemp"].ValuesByOffset[8] = 500; // Severity at offset 8 (after InFaulted+Acked)
await Task.Delay(80); // let a tick seed the "last-seen false" state
factory.Tags["HighTemp"].ValuesByOffset[0] = 1; // flip to faulted
await Task.Delay(200); // allow several polls to be safe
lock (events)
{
events.ShouldContain(e => e.SourceNodeId == "HighTemp" && e.AlarmType == "ALMD"
&& e.Message.Contains("raised"));
}
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Clear_Event_Fires_On_1_to_0_Transition()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var events = new List<AlarmEventArgs>();
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
factory.Tags["HighTemp"].ValuesByOffset[0] = 1;
factory.Tags["HighTemp"].ValuesByOffset[8] = 500;
await Task.Delay(80); // observe raise
factory.Tags["HighTemp"].ValuesByOffset[0] = 0;
await Task.Delay(200);
lock (events)
{
events.ShouldContain(e => e.Message.Contains("raised"));
events.ShouldContain(e => e.Message.Contains("cleared"));
}
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Unsubscribe_Stops_The_Poll_Loop()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = [AlmdTag("HighTemp")],
EnableAlarmProjection = true,
AlarmPollInterval = TimeSpan.FromMilliseconds(20),
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var handle = await drv.SubscribeAlarmsAsync(["HighTemp"], CancellationToken.None);
await WaitForTagCreation(factory, "HighTemp");
var preUnsubReadCount = factory.Tags["HighTemp"].ReadCount;
await drv.UnsubscribeAlarmsAsync(handle, CancellationToken.None);
await Task.Delay(100); // well past several poll intervals if the loop were still alive
var postDelayReadCount = factory.Tags["HighTemp"].ReadCount;
// Allow at most one straggler read between the unsubscribe-cancel + the loop exit.
(postDelayReadCount - preUnsubReadCount).ShouldBeLessThanOrEqualTo(1);
await drv.ShutdownAsync(CancellationToken.None);
}
private static async Task WaitForTagCreation(FakeAbCipTagFactory factory, string tagName)
{
var deadline = DateTime.UtcNow.AddSeconds(2);
while (DateTime.UtcNow < deadline)
{
if (factory.Tags.ContainsKey(tagName)) return;
await Task.Delay(10);
}
throw new TimeoutException($"Tag {tagName} was never created by the fake factory.");
}
}

View File

@@ -0,0 +1,152 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipBoolInDIntRmwTests
{
/// <summary>
/// Fake tag runtime that stores a DINT value + exposes Read/Write/EncodeValue/DecodeValue
/// for DInt. RMW tests use one instance as the "parent" runtime (tag name "Motor.Flags")
/// which the driver's WriteBitInDIntAsync reads + writes.
/// </summary>
private sealed class ParentDintFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
// Uses the base FakeAbCipTag's Value + ReadCount + WriteCount.
}
[Fact]
public async Task Bit_set_reads_parent_ORs_bit_writes_back()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = 0b0001 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Flag3", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
// Parent runtime created under name "Motor.Flags" — distinct from the bit-selector tag.
factory.Tags.ShouldContainKey("Motor.Flags");
factory.Tags["Motor.Flags"].Value.ShouldBe(0b1001); // bit 3 set, bit 0 preserved
factory.Tags["Motor.Flags"].ReadCount.ShouldBe(1);
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
}
[Fact]
public async Task Bit_clear_preserves_other_bits()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = unchecked((int)0xFFFFFFFF) },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("F", "ab://10.0.0.5/1,0", "Motor.Flags.3", AbCipDataType.Bool)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
var updated = Convert.ToInt32(factory.Tags["Motor.Flags"].Value);
(updated & (1 << 3)).ShouldBe(0); // bit 3 cleared
(updated & ~(1 << 3)).ShouldBe(unchecked((int)0xFFFFFFF7)); // every other bit preserved
}
[Fact]
public async Task Concurrent_bit_writes_to_same_parent_compose_correctly()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = 0 },
};
var tags = Enumerable.Range(0, 8)
.Select(b => new AbCipTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"Flags.{b}", AbCipDataType.Bool))
.ToArray();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0xFF);
}
[Fact]
public async Task Bit_writes_to_different_parents_each_get_own_runtime()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = 0 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "Motor1.Flags.0", AbCipDataType.Bool),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "Motor2.Flags.0", AbCipDataType.Bool),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("A", true)], CancellationToken.None);
await drv.WriteAsync([new WriteRequest("B", true)], CancellationToken.None);
factory.Tags.ShouldContainKey("Motor1.Flags");
factory.Tags.ShouldContainKey("Motor2.Flags");
}
[Fact]
public async Task Repeat_bit_writes_reuse_one_parent_runtime()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new ParentDintFake(p) { Value = 0 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Bit0", "ab://10.0.0.5/1,0", "Flags.0", AbCipDataType.Bool),
new AbCipTagDefinition("Bit5", "ab://10.0.0.5/1,0", "Flags.5", AbCipDataType.Bool),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Bit0", true)], CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Bit5", true)], CancellationToken.None);
// Three factory invocations: two bit-selector tags (never used for writes, but the
// driver may create them opportunistically) + one shared parent. Assert the parent was
// init'd exactly once + used for both writes.
factory.Tags["Flags"].InitializeCount.ShouldBe(1);
factory.Tags["Flags"].WriteCount.ShouldBe(2);
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0x21); // bits 0 + 5
}
}

View File

@@ -0,0 +1,282 @@
using System.Runtime.CompilerServices;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverDiscoveryTests
{
[Fact]
public async Task PreDeclared_tags_emit_as_variables_under_device_folder()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")],
Tags =
[
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "AbCip");
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC");
builder.Variables.Count.ShouldBe(2);
builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task Device_folder_displayname_falls_back_to_host_address()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], // no DeviceName
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0"
&& f.DisplayName == "ab://10.0.0.5/1,0");
}
[Fact]
public async Task PreDeclared_system_tags_are_filtered_out()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt),
new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt),
new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
}
[Fact]
public async Task Tags_for_mismatched_device_are_ignored()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldBeEmpty();
}
[Fact]
public async Task Controller_enumeration_adds_tags_under_Discovered_folder()
{
var builder = new RecordingBuilder();
var enumeratorFactory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false),
new AbCipDiscoveredTag("StepIndex", ProgramScope: "MainProgram", AbCipDataType.DInt, ReadOnly: false));
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: enumeratorFactory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Pressure");
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex");
}
[Fact]
public async Task Controller_enumeration_honours_system_tag_hint_and_filter()
{
var builder = new RecordingBuilder();
var factory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("HiddenByHint", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: true),
new AbCipDiscoveredTag("Routine:Foo", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: false),
new AbCipDiscoveredTag("KeepMe", null, AbCipDataType.DInt, ReadOnly: false));
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
}
[Fact]
public async Task Controller_enumeration_ReadOnly_surfaces_ViewOnly_classification()
{
var builder = new RecordingBuilder();
var factory = new FakeEnumeratorFactory(
new AbCipDiscoveredTag("SafetyTag", null, AbCipDataType.DInt, ReadOnly: true));
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task Controller_enumeration_receives_correct_device_params()
{
var factory = new FakeEnumeratorFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
Timeout = TimeSpan.FromSeconds(7),
EnableControllerBrowse = true,
}, "drv-1", enumeratorFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None);
var capturedParams = factory.LastDeviceParams.ShouldNotBeNull();
capturedParams.Gateway.ShouldBe("10.0.0.5");
capturedParams.Port.ShouldBe(44818);
capturedParams.CipPath.ShouldBe("1,2,3");
capturedParams.LibplctagPlcAttribute.ShouldBe("controllogix");
capturedParams.TagName.ShouldBe("@tags");
capturedParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
}
[Fact]
public void Default_enumerator_factory_is_used_when_not_injected()
{
// Sanity — absent enumerator factory does not crash discovery + uses EmptyAbCipTagEnumerator
// (covered by the other tests which instantiate without injecting a factory).
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1");
drv.ShouldNotBeNull();
}
[Theory]
[InlineData("__DEFVAL_X", true)]
[InlineData("__DEFAULT_Y", true)]
[InlineData("Routine:Main", true)]
[InlineData("Task:MainTask", true)]
[InlineData("Local:1:I", true)]
[InlineData("Map:Alias", true)]
[InlineData("Axis:MoveX", true)]
[InlineData("Cam:Profile1", true)]
[InlineData("MotionGroup:MG0", true)]
[InlineData("Motor1", false)]
[InlineData("Program:Main.Step", false)]
[InlineData("Recipe_2", false)]
[InlineData("", true)]
[InlineData(" ", true)]
public void SystemTagFilter_rejects_infrastructure_names(string name, bool expected)
{
AbCipSystemTagFilter.IsSystemTag(name).ShouldBe(expected);
}
[Fact]
public void TemplateCache_roundtrip_put_get()
{
var cache = new AbCipTemplateCache();
var shape = new AbCipUdtShape("MyUdt", 32,
[
new AbCipUdtMember("A", 0, AbCipDataType.DInt, ArrayLength: 1),
new AbCipUdtMember("B", 4, AbCipDataType.Real, ArrayLength: 1),
]);
cache.Put("ab://10.0.0.5/1,0", 42, shape);
cache.TryGet("ab://10.0.0.5/1,0", 42).ShouldBe(shape);
cache.TryGet("ab://10.0.0.5/1,0", 99).ShouldBeNull();
cache.Count.ShouldBe(1);
cache.Clear();
cache.Count.ShouldBe(0);
}
[Fact]
public async Task FlushOptionalCachesAsync_clears_template_cache()
{
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
drv.TemplateCache.Put("dev", 1, new AbCipUdtShape("T", 4, []));
drv.TemplateCache.Count.ShouldBe(1);
await drv.FlushOptionalCachesAsync(CancellationToken.None);
drv.TemplateCache.Count.ShouldBe(0);
}
// ---- helpers ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory
{
private readonly AbCipDiscoveredTag[] _tags;
public AbCipTagCreateParams? LastDeviceParams { get; private set; }
public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
public IAbCipTagEnumerator Create() => new FakeEnumerator(this);
private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator
{
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
outer.LastDeviceParams = deviceParams;
await Task.CompletedTask;
foreach (var t in outer._tags) yield return t;
}
public void Dispose() { }
}
}
}

View File

@@ -0,0 +1,214 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverReadTests
{
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
};
var drv = new AbCipDriver(opts, "drv-1", factory);
return (drv, factory);
}
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
var (drv, _) = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["does-not-exist"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
snapshots.Single().Value.ShouldBeNull();
}
[Fact]
public async Task Tag_on_unknown_device_maps_to_BadNodeIdUnknown()
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "Tag1", AbCipDataType.DInt)],
};
var drv = new AbCipDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["Orphan"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Successful_DInt_read_returns_Good_with_value()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
// Customise the fake before the first read so the tag returns 4200.
factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
snapshots.Single().Value.ShouldBe(4200);
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1);
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Repeat_read_reuses_runtime_without_reinitialise()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
await drv.ReadAsync(["Speed"], CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
await drv.ReadAsync(["Speed"], CancellationToken.None);
factory.Tags["Motor1.Speed"].InitializeCount.ShouldBe(1); // lazy init happens once
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
}
[Fact]
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Ghost", "ab://10.0.0.5/1,0", "Missing.Tag", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Status = -14 /* PLCTAG_ERR_NOT_FOUND */ };
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
snapshots.Single().Value.ShouldBeNull();
}
[Fact]
public async Task Exception_during_read_surfaces_BadCommunicationError()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.Real));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true };
var snapshots = await drv.ReadAsync(["Broken"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
snapshots.Single().Value.ShouldBeNull();
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
[Fact]
public async Task Batched_reads_preserve_order_and_per_tag_status()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => p.TagName switch
{
"A" => new FakeAbCipTag(p) { Value = 42 },
"B" => new FakeAbCipTag(p) { Value = 3.14f },
_ => new FakeAbCipTag(p) { Value = "hello" },
};
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
snapshots[0].Value.ShouldBe(42);
snapshots[1].Value.ShouldBe(3.14f);
snapshots[2].Value.ShouldBe("hello");
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
}
[Fact]
public async Task Successful_read_marks_health_Healthy()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Pressure", "ab://10.0.0.5/1,0", "PT_101", AbCipDataType.Real));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 14.7f };
await drv.ReadAsync(["Pressure"], CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
}
[Fact]
public async Task TagCreateParams_are_built_from_device_and_profile()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Counter", "ab://10.0.0.5/1,0", "Program:P.Counter", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Counter"], CancellationToken.None);
var p = factory.Tags["Program:P.Counter"].CreationParams;
p.Gateway.ShouldBe("10.0.0.5");
p.Port.ShouldBe(44818);
p.CipPath.ShouldBe("1,0");
p.LibplctagPlcAttribute.ShouldBe("controllogix");
p.TagName.ShouldBe("Program:P.Counter");
}
[Fact]
public async Task Cancellation_propagates_from_read()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p)
{
ThrowOnRead = true,
Exception = new OperationCanceledException(),
};
using var cts = new CancellationTokenSource();
await Should.ThrowAsync<OperationCanceledException>(
() => drv.ReadAsync(["Slow"], cts.Token));
}
[Fact]
public async Task ShutdownAsync_disposes_each_tag_runtime()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
await drv.ReadAsync(["A", "B"], CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
factory.Tags["A"].Disposed.ShouldBeTrue();
factory.Tags["B"].Disposed.ShouldBeTrue();
}
[Fact]
public async Task Initialize_failure_disposes_tag_and_surfaces_communication_error()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("DoomedTag", "ab://10.0.0.5/1,0", "Nope", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { ThrowOnInitialize = true };
var snapshots = await drv.ReadAsync(["DoomedTag"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
factory.Tags["Nope"].Disposed.ShouldBeTrue();
}
}

View File

@@ -0,0 +1,131 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverTests
{
[Fact]
public void DriverType_is_AbCip()
{
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
drv.DriverType.ShouldBe("AbCip");
drv.DriverInstanceId.ShouldBe("drv-1");
}
[Fact]
public async Task InitializeAsync_with_empty_devices_succeeds_and_marks_healthy()
{
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public async Task InitializeAsync_registers_each_device_with_its_family_profile()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix),
new AbCipDeviceOptions("ab://10.0.0.6/", AbCipPlcFamily.Micro800),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(2);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.ShouldBe(AbCipPlcFamilyProfile.ControlLogix);
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbCipPlcFamilyProfile.Micro800);
}
[Fact]
public async Task InitializeAsync_with_malformed_host_address_faults()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("not-a-valid-address")],
}, "drv-1");
await Should.ThrowAsync<InvalidOperationException>(
() => drv.InitializeAsync("{}", CancellationToken.None));
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
[Fact]
public async Task ShutdownAsync_clears_devices_and_marks_unknown()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
await drv.ShutdownAsync(CancellationToken.None);
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
[Fact]
public async Task ReinitializeAsync_cycles_devices()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReinitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
[Fact]
public void Family_profiles_expose_expected_defaults()
{
AbCipPlcFamilyProfile.ControlLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize.ShouldBe(4002);
AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath.ShouldBe("1,0");
AbCipPlcFamilyProfile.Micro800.DefaultCipPath.ShouldBe(""); // no backplane routing
AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
AbCipPlcFamilyProfile.Micro800.SupportsConnectedMessaging.ShouldBeFalse();
AbCipPlcFamilyProfile.CompactLogix.DefaultConnectionSize.ShouldBe(504);
AbCipPlcFamilyProfile.GuardLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
}
[Fact]
public void PlcTagHandle_IsInvalid_for_zero_or_negative_native_id()
{
PlcTagHandle.FromNative(-5).IsInvalid.ShouldBeTrue();
PlcTagHandle.FromNative(0).IsInvalid.ShouldBeTrue();
PlcTagHandle.FromNative(42).IsInvalid.ShouldBeFalse();
}
[Fact]
public void PlcTagHandle_Dispose_is_idempotent()
{
var h = PlcTagHandle.FromNative(42);
h.Dispose();
h.Dispose(); // must not throw
}
[Fact]
public void AbCipDataType_maps_atomics_to_driver_types()
{
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
}
}

View File

@@ -0,0 +1,130 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Task #194 — ReadAsync integration tests for the whole-UDT grouping path. The fake
/// runtime records ReadCount + surfaces member values by byte offset so we can assert
/// both "one read per parent UDT" and "each member decoded at the correct offset."
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDriverWholeUdtReadTests
{
private const string Device = "ab://10.0.0.5/1,0";
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
{
var factory = new FakeAbCipTagFactory();
var opts = new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
Tags = tags,
};
return (new AbCipDriver(opts, "drv-1", factory), factory);
}
private static AbCipTagDefinition MotorUdt() => new(
"Motor", Device, "Motor", AbCipDataType.Structure, Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt), // offset 0
new AbCipStructureMember("Torque", AbCipDataType.Real), // offset 4
]);
[Fact]
public async Task Two_members_of_same_udt_trigger_one_parent_read()
{
var (drv, factory) = NewDriver(MotorUdt());
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
snapshots.Count.ShouldBe(2);
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
// Factory should have created ONE runtime (for the parent "Motor") + issued ONE read.
// Without the optimization two runtimes (one per member) + two reads would appear.
factory.Tags.Count.ShouldBe(1);
factory.Tags.ShouldContainKey("Motor");
factory.Tags["Motor"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Each_member_decodes_at_its_own_offset()
{
var (drv, factory) = NewDriver(MotorUdt());
await drv.InitializeAsync("{}", CancellationToken.None);
// Arrange the offset-keyed values before the read fires — the planner places
// Speed at offset 0 (DInt) and Torque at offset 4 (Real).
// The fake records CreationParams so we fetch it up front by the parent name.
var snapshotsTask = drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
// The factory creates the runtime inside ReadAsync; we need to set the offset map
// AFTER creation. Easier path: create the runtime on demand by reading once then
// re-arming. Instead: seed via a pre-read by constructing the fake in the factory's
// customise hook.
var snapshots = await snapshotsTask;
// First run establishes the runtime + gives the fake a chance to hold its reference.
factory.Tags["Motor"].ValuesByOffset[0] = 1234; // Speed
factory.Tags["Motor"].ValuesByOffset[4] = 9.5f; // Torque
snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
snapshots[0].Value.ShouldBe(1234);
snapshots[1].Value.ShouldBe(9.5f);
}
[Fact]
public async Task Parent_read_failure_stamps_every_grouped_member_Bad()
{
var (drv, factory) = NewDriver(MotorUdt());
await drv.InitializeAsync("{}", CancellationToken.None);
// Prime runtime existence via a first (successful) read so we can flip it to error.
await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
factory.Tags["Motor"].Status = -3; // libplctag BadTimeout — mapped in AbCipStatusMapper
var snapshots = await drv.ReadAsync(["Motor.Speed", "Motor.Torque"], CancellationToken.None);
snapshots.Count.ShouldBe(2);
snapshots[0].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
snapshots[0].Value.ShouldBeNull();
snapshots[1].StatusCode.ShouldNotBe(AbCipStatusMapper.Good);
snapshots[1].Value.ShouldBeNull();
}
[Fact]
public async Task Mixed_batch_groups_udt_and_falls_back_atomics()
{
var plain = new AbCipTagDefinition("PlainDint", Device, "PlainDint", AbCipDataType.DInt);
var (drv, factory) = NewDriver(MotorUdt(), plain);
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(
["Motor.Speed", "PlainDint", "Motor.Torque"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
// Motor parent ran one read, PlainDint ran its own read = 2 runtimes, 2 reads total.
factory.Tags.Count.ShouldBe(2);
factory.Tags.ShouldContainKey("Motor");
factory.Tags.ShouldContainKey("PlainDint");
factory.Tags["Motor"].ReadCount.ShouldBe(1);
factory.Tags["PlainDint"].ReadCount.ShouldBe(1);
}
[Fact]
public async Task Single_member_of_Udt_uses_per_tag_read_path()
{
// One member of a UDT doesn't benefit from grouping — the planner demotes to
// fallback so the member-level runtime (distinct from the parent runtime) is used,
// matching pre-#194 behavior.
var (drv, factory) = NewDriver(MotorUdt());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["Motor.Speed"], CancellationToken.None);
factory.Tags.ShouldContainKey("Motor.Speed");
factory.Tags.ShouldNotContainKey("Motor");
}
}

View File

@@ -0,0 +1,233 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverWriteTests
{
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
}, "drv-1", factory);
return (drv, factory);
}
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
var (drv, _) = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("does-not-exist", 1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
[Fact]
public async Task Non_writable_tag_maps_to_BadNotWritable()
{
var (drv, _) = NewDriver(
new AbCipTagDefinition("ReadOnly", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("ReadOnly", 7)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
}
[Fact]
public async Task Successful_DInt_write_encodes_and_flushes()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Speed", 4200)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
factory.Tags["Motor1.Speed"].Value.ShouldBe(4200);
factory.Tags["Motor1.Speed"].WriteCount.ShouldBe(1);
}
[Fact]
public async Task Bit_in_dint_write_now_succeeds_via_RMW()
{
// Task #181 pass 2 lifted this gap — BOOL-within-DINT writes now go through
// WriteBitInDIntAsync + a parallel parent-DINT runtime, so the result is Good rather
// than BadNotSupported. Full RMW semantics covered by AbCipBoolInDIntRmwTests.
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Flag3", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
[Fact]
public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Status = -5 /* timeout */ };
var results = await drv.WriteAsync(
[new WriteRequest("Broken", 1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
}
[Fact]
public async Task Type_mismatch_surfaces_BadTypeMismatch()
{
var (drv, _) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
// Force a FormatException inside Convert.ToInt32 via a runtime that forwards to real Convert.
var factory = new FakeAbCipTagFactory
{
Customise = p => new RealConvertFake(p),
};
var drv2 = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt)],
}, "drv-2", factory);
await drv2.InitializeAsync("{}", CancellationToken.None);
var results = await drv2.WriteAsync(
[new WriteRequest("Speed", "not-a-number")], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
}
[Fact]
public async Task Overflow_surfaces_BadOutOfRange()
{
var factory = new FakeAbCipTagFactory { Customise = p => new RealConvertFake(p) };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("Narrow", "ab://10.0.0.5/1,0", "N", AbCipDataType.Int)],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Narrow", 1_000_000)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
}
[Fact]
public async Task Exception_during_write_surfaces_BadCommunicationError()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Broken", "ab://10.0.0.5/1,0", "Broken", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new ThrowOnWriteFake(p);
var results = await drv.WriteAsync(
[new WriteRequest("Broken", 1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
[Fact]
public async Task Batch_preserves_order_across_success_and_failure()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt, Writable: false),
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[
new WriteRequest("A", 1),
new WriteRequest("B", 2),
new WriteRequest("UnknownTag", 3),
new WriteRequest("C", 4),
], CancellationToken.None);
results.Count.ShouldBe(4);
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
results[3].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
[Fact]
public async Task Cancellation_propagates_from_write()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Slow", "ab://10.0.0.5/1,0", "Slow", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new CancelOnWriteFake(p);
await Should.ThrowAsync<OperationCanceledException>(
() => drv.WriteAsync([new WriteRequest("Slow", 1)], CancellationToken.None));
}
// ---- test-fake variants that exercise the real type / error handling ----
private sealed class RealConvertFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
switch (type)
{
case AbCipDataType.Int: _ = Convert.ToInt16(value); break;
case AbCipDataType.DInt: _ = Convert.ToInt32(value); break;
default: _ = Convert.ToInt32(value); break;
}
Value = value;
}
}
private sealed class ThrowingBoolBitFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
if (type == AbCipDataType.Bool && bitIndex is not null)
throw new NotSupportedException("bit-in-DINT deferred");
Value = value;
}
}
private sealed class ThrowOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override Task WriteAsync(CancellationToken ct) =>
Task.FromException(new InvalidOperationException("wire dropped"));
}
private sealed class CancelOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override Task WriteAsync(CancellationToken ct) =>
Task.FromException(new OperationCanceledException());
}
}

View File

@@ -0,0 +1,221 @@
using System.Buffers.Binary;
using System.Reflection;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipFetchUdtShapeTests
{
private sealed class FakeTemplateReader : IAbCipTemplateReader
{
public byte[] Response { get; set; } = [];
public int ReadCount { get; private set; }
public bool Disposed { get; private set; }
public uint LastTemplateId { get; private set; }
public Task<byte[]> ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct)
{
ReadCount++;
LastTemplateId = templateInstanceId;
return Task.FromResult(Response);
}
public void Dispose() => Disposed = true;
}
private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory
{
public List<IAbCipTemplateReader> Readers { get; } = new();
public Func<IAbCipTemplateReader>? Customise { get; set; }
public IAbCipTemplateReader Create()
{
var r = Customise?.Invoke() ?? new FakeTemplateReader();
Readers.Add(r);
return r;
}
}
private static byte[] BuildSimpleTemplate(string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
{
var headerSize = 12;
var blockSize = 8;
var strings = new MemoryStream();
void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
Add(name);
foreach (var m in members) Add(m.n);
var stringsArr = strings.ToArray();
var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + i * blockSize;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
}
Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
return buf;
}
private static Task<AbCipUdtShape?> InvokeFetch(AbCipDriver drv, string deviceHostAddress, uint templateId)
{
var mi = typeof(AbCipDriver).GetMethod("FetchUdtShapeAsync",
BindingFlags.NonPublic | BindingFlags.Instance)!;
return (Task<AbCipUdtShape?>)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!;
}
[Fact]
public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new FakeTemplateReader
{
Response = BuildSimpleTemplate("MotorUdt", 8,
("Speed", 0xC4, 0, 0),
("Enabled", 0xC1, 0, 4)),
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
shape.ShouldNotBeNull();
shape.TypeName.ShouldBe("MotorUdt");
shape.Members.Count.ShouldBe(2);
// Second fetch must hit the cache — no second reader created.
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 42);
factory.Readers.Count.ShouldBe(1);
}
[Fact]
public async Task FetchUdtShapeAsync_different_templateIds_each_fetch()
{
var callCount = 0;
var factory = new FakeTemplateReaderFactory
{
Customise = () =>
{
callCount++;
var name = callCount == 1 ? "UdtA" : "UdtB";
return new FakeTemplateReader
{
Response = BuildSimpleTemplate(name, 4, ("X", 0xC4, 0, 0)),
};
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var a = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
var b = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 2);
a!.TypeName.ShouldBe("UdtA");
b!.TypeName.ShouldBe("UdtB");
factory.Readers.Count.ShouldBe(2);
}
[Fact]
public async Task FetchUdtShapeAsync_unknown_device_returns_null()
{
var factory = new FakeTemplateReaderFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.99/1,0", 1);
shape.ShouldBeNull();
factory.Readers.ShouldBeEmpty();
}
[Fact]
public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new FakeTemplateReader { Response = [0x00, 0x00] }, // too short
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
shape.ShouldBeNull();
// Next call retries (not cached as a failure).
var shape2 = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
shape2.ShouldBeNull();
factory.Readers.Count.ShouldBe(2);
}
[Fact]
public async Task FetchUdtShapeAsync_reader_exception_returns_null()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new ThrowingTemplateReader(),
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var shape = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 1);
shape.ShouldBeNull();
}
[Fact]
public async Task FlushOptionalCachesAsync_empties_template_cache()
{
var factory = new FakeTemplateReaderFactory
{
Customise = () => new FakeTemplateReader
{
Response = BuildSimpleTemplate("U", 4, ("X", 0xC4, 0, 0)),
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
}, "drv-1", templateReaderFactory: factory);
await drv.InitializeAsync("{}", CancellationToken.None);
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
drv.TemplateCache.Count.ShouldBe(1);
await drv.FlushOptionalCachesAsync(CancellationToken.None);
drv.TemplateCache.Count.ShouldBe(0);
// Next fetch hits the network again.
_ = await InvokeFetch(drv, "ab://10.0.0.5/1,0", 99);
factory.Readers.Count.ShouldBe(2);
}
private sealed class ThrowingTemplateReader : IAbCipTemplateReader
{
public Task<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
throw new InvalidOperationException("fake read failure");
public void Dispose() { }
}
}

View File

@@ -0,0 +1,65 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipHostAddressTests
{
[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")]
[InlineData("ab://10.0.0.5/1,2,2,192.168.50.20,1,0", "10.0.0.5", 44818, "1,2,2,192.168.50.20,1,0")]
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
[InlineData("ab://plc-01.factory.internal/1,0", "plc-01.factory.internal", 44818, "1,0")]
[InlineData("ab://10.0.0.5:44818/1,0", "10.0.0.5", 44818, "1,0")]
[InlineData("ab://10.0.0.5:2222/1,0", "10.0.0.5", 2222, "1,0")]
[InlineData("AB://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")] // case-insensitive scheme
public void TryParse_accepts_valid_forms(string input, string gateway, int port, string cipPath)
{
var parsed = AbCipHostAddress.TryParse(input);
parsed.ShouldNotBeNull();
parsed.Gateway.ShouldBe(gateway);
parsed.Port.ShouldBe(port);
parsed.CipPath.ShouldBe(cipPath);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("http://10.0.0.5/1,0")] // wrong scheme
[InlineData("ab:10.0.0.5/1,0")] // missing //
[InlineData("ab://10.0.0.5")] // no path slash
[InlineData("ab:///1,0")] // no gateway
[InlineData("ab://10.0.0.5:0/1,0")] // invalid port
[InlineData("ab://10.0.0.5:65536/1,0")] // port out of range
[InlineData("ab://10.0.0.5:abc/1,0")] // non-numeric port
public void TryParse_rejects_invalid_forms(string? input)
{
AbCipHostAddress.TryParse(input).ShouldBeNull();
}
[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")]
[InlineData("10.0.0.5", 44818, "", "ab://10.0.0.5/")]
public void ToString_canonicalises(string gateway, int port, string path, string expected)
{
var addr = new AbCipHostAddress(gateway, port, path);
addr.ToString().ShouldBe(expected);
}
[Fact]
public void RoundTrip_is_stable()
{
const string input = "ab://plc-01:44818/1,2,2,10.0.0.10,1,0";
var parsed = AbCipHostAddress.TryParse(input)!;
// Default port is stripped in canonical form; explicit 44818 → becomes default form.
parsed.ToString().ShouldBe("ab://plc-01/1,2,2,10.0.0.10,1,0");
var parsedAgain = AbCipHostAddress.TryParse(parsed.ToString())!;
parsedAgain.ShouldBe(parsed);
}
}

View File

@@ -0,0 +1,227 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipHostProbeTests
{
[Fact]
public async Task GetHostStatuses_returns_one_entry_per_device()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
var statuses = drv.GetHostStatuses();
statuses.Count.ShouldBe(2);
statuses.Select(s => s.HostName).ShouldBe(["ab://10.0.0.5/1,0", "ab://10.0.0.6/1,0"], ignoreOrder: true);
statuses.ShouldAllBe(s => s.State == HostState.Unknown);
}
[Fact]
public async Task Probe_with_successful_read_transitions_to_Running()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Status = 0 } };
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
transitions.Select(t => t.NewState).ShouldContain(HostState.Running);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_with_read_failure_transitions_to_Stopped()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new FakeAbCipTag(p) { ThrowOnRead = true },
};
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_disabled_when_Enabled_is_false()
{
var factory = new FakeAbCipTagFactory();
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions { Enabled = false, ProbeTagPath = "@raw_cpu_type" },
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(300);
transitions.ShouldBeEmpty();
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_skipped_when_ProbeTagPath_is_null()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions { Enabled = true, ProbeTagPath = null },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(200);
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Unknown);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task Probe_loops_across_multiple_devices_independently()
{
var factory = new FakeAbCipTagFactory
{
// Device A returns ok, Device B throws on read.
Customise = p => p.Gateway == "10.0.0.5"
? new FakeAbCipTag(p)
: new FakeAbCipTag(p) { ThrowOnRead = true },
};
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Probe = new AbCipProbeOptions
{
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
Timeout = TimeSpan.FromMilliseconds(50), ProbeTagPath = "@raw_cpu_type",
},
}, "drv-1", factory);
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitForAsync(() => transitions.Count >= 2, TimeSpan.FromSeconds(3));
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.5/1,0" && t.NewState == HostState.Running);
transitions.ShouldContain(t => t.HostName == "ab://10.0.0.6/1,0" && t.NewState == HostState.Stopped);
await drv.ShutdownAsync(CancellationToken.None);
}
// ---- IPerCallHostResolver ----
[Fact]
public async Task ResolveHost_returns_declared_device_for_known_tag()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices =
[
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
],
Tags =
[
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("B", "ab://10.0.0.6/1,0", "B", AbCipDataType.DInt),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("A").ShouldBe("ab://10.0.0.5/1,0");
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
}
[Fact]
public async Task ResolveHost_falls_back_to_first_device_for_unknown_reference()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("does-not-exist").ShouldBe("ab://10.0.0.5/1,0");
}
[Fact]
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
{
var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("anything").ShouldBe("drv-1");
}
[Fact]
public async Task ResolveHost_for_UDT_member_walks_to_synthesised_definition()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.7/1,0")],
Tags =
[
new AbCipTagDefinition("Motor1", "ab://10.0.0.7/1,0", "Motor1", AbCipDataType.Structure,
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.ResolveHost("Motor1.Speed").ShouldBe("ab://10.0.0.7/1,0");
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}

View File

@@ -0,0 +1,209 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipPlcFamilyTests
{
// ---- ControlLogix ----
[Fact]
public void ControlLogix_profile_defaults_match_large_forward_open_baseline()
{
var p = AbCipPlcFamilyProfile.ControlLogix;
p.LibplctagPlcAttribute.ShouldBe("controllogix");
p.DefaultConnectionSize.ShouldBe(4002); // LFO — FW20+
p.DefaultCipPath.ShouldBe("1,0");
p.SupportsRequestPacking.ShouldBeTrue();
p.SupportsConnectedMessaging.ShouldBeTrue();
p.MaxFragmentBytes.ShouldBe(4000);
}
[Fact]
public async Task ControlLogix_device_initialises_with_correct_profile()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState("ab://10.0.0.5/1,0")!.Profile.LibplctagPlcAttribute.ShouldBe("controllogix");
}
// ---- CompactLogix ----
[Fact]
public void CompactLogix_profile_uses_narrower_connection_size()
{
var p = AbCipPlcFamilyProfile.CompactLogix;
p.LibplctagPlcAttribute.ShouldBe("compactlogix");
p.DefaultConnectionSize.ShouldBe(504); // 5069-L3x narrow-window safety
p.DefaultCipPath.ShouldBe("1,0");
p.SupportsRequestPacking.ShouldBeTrue();
p.SupportsConnectedMessaging.ShouldBeTrue();
p.MaxFragmentBytes.ShouldBe(500);
}
[Fact]
public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://192.168.1.10/1,0", AbCipPlcFamily.CompactLogix)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
var profile = drv.GetDeviceState("ab://192.168.1.10/1,0")!.Profile;
profile.DefaultConnectionSize.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
profile.MaxFragmentBytes.ShouldBeLessThan(AbCipPlcFamilyProfile.ControlLogix.MaxFragmentBytes);
}
// ---- Micro800 ----
[Fact]
public void Micro800_profile_is_unconnected_only_with_empty_path()
{
var p = AbCipPlcFamilyProfile.Micro800;
p.LibplctagPlcAttribute.ShouldBe("micro800");
p.DefaultConnectionSize.ShouldBe(488);
p.DefaultCipPath.ShouldBe(""); // no backplane routing
p.SupportsRequestPacking.ShouldBeFalse();
p.SupportsConnectedMessaging.ShouldBeFalse();
p.MaxFragmentBytes.ShouldBe(484);
}
[Fact]
public async Task Micro800_device_with_empty_cip_path_parses_correctly()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
var state = drv.GetDeviceState("ab://192.168.1.20/")!;
state.ParsedAddress.CipPath.ShouldBe("");
state.Profile.SupportsRequestPacking.ShouldBeFalse();
state.Profile.SupportsConnectedMessaging.ShouldBeFalse();
}
[Fact]
public async Task Micro800_read_forwards_empty_path_to_tag_create_params()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Value = 123 } };
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://192.168.1.20/", AbCipPlcFamily.Micro800)],
Tags = [new AbCipTagDefinition("X", "ab://192.168.1.20/", "X", AbCipDataType.DInt)],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
factory.Tags["X"].CreationParams.CipPath.ShouldBe("");
factory.Tags["X"].CreationParams.LibplctagPlcAttribute.ShouldBe("micro800");
}
// ---- GuardLogix ----
[Fact]
public void GuardLogix_profile_wire_protocol_mirrors_ControlLogix()
{
var p = AbCipPlcFamilyProfile.GuardLogix;
// Wire protocol is identical to ControlLogix — only the safety-partition semantics differ,
// which is a per-tag concern surfaced via AbCipTagDefinition.SafetyTag.
p.LibplctagPlcAttribute.ShouldBe("controllogix");
p.DefaultConnectionSize.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultConnectionSize);
p.DefaultCipPath.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath);
}
[Fact]
public async Task GuardLogix_safety_tag_surfaces_as_ViewOnly_in_discovery()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
Tags =
[
new AbCipTagDefinition("NormalTag", "ab://10.0.0.5/1,0", "N", AbCipDataType.DInt),
new AbCipTagDefinition("SafetyTag", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
Writable: true, SafetyTag: true),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Single(v => v.BrowseName == "NormalTag").Info.SecurityClass
.ShouldBe(SecurityClassification.Operate);
builder.Variables.Single(v => v.BrowseName == "SafetyTag").Info.SecurityClass
.ShouldBe(SecurityClassification.ViewOnly);
}
[Fact]
public async Task GuardLogix_safety_tag_writes_rejected_even_when_Writable_is_true()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.GuardLogix)],
Tags =
[
new AbCipTagDefinition("SafetySet", "ab://10.0.0.5/1,0", "S", AbCipDataType.DInt,
Writable: true, SafetyTag: true),
],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("SafetySet", 42)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
}
// ---- ForFamily dispatch ----
[Theory]
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
[InlineData(AbCipPlcFamily.Micro800, "micro800")]
[InlineData(AbCipPlcFamily.GuardLogix, "controllogix")]
public void ForFamily_dispatches_to_correct_profile(AbCipPlcFamily family, string expectedAttribute)
{
AbCipPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
}
// ---- helpers ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}

View File

@@ -0,0 +1,41 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipStatusMapperTests
{
[Theory]
[InlineData((byte)0x00, AbCipStatusMapper.Good)]
[InlineData((byte)0x04, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData((byte)0x05, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData((byte)0x06, AbCipStatusMapper.GoodMoreData)]
[InlineData((byte)0x08, AbCipStatusMapper.BadNotSupported)]
[InlineData((byte)0x0A, AbCipStatusMapper.BadOutOfRange)]
[InlineData((byte)0x13, AbCipStatusMapper.BadOutOfRange)]
[InlineData((byte)0x0B, AbCipStatusMapper.Good)]
[InlineData((byte)0x0E, AbCipStatusMapper.BadNotWritable)]
[InlineData((byte)0x10, AbCipStatusMapper.BadDeviceFailure)]
[InlineData((byte)0x16, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData((byte)0xFF, AbCipStatusMapper.BadInternalError)]
public void MapCipGeneralStatus_maps_known_codes(byte status, uint expected)
{
AbCipStatusMapper.MapCipGeneralStatus(status).ShouldBe(expected);
}
[Theory]
[InlineData(0, AbCipStatusMapper.Good)]
[InlineData(1, AbCipStatusMapper.GoodMoreData)] // PLCTAG_STATUS_PENDING
[InlineData(-5, AbCipStatusMapper.BadTimeout)]
[InlineData(-7, AbCipStatusMapper.BadCommunicationError)]
[InlineData(-14, AbCipStatusMapper.BadNodeIdUnknown)]
[InlineData(-16, AbCipStatusMapper.BadNotWritable)]
[InlineData(-17, AbCipStatusMapper.BadOutOfRange)]
[InlineData(-99, AbCipStatusMapper.BadCommunicationError)] // unknown negative → generic comms failure
public void MapLibplctagStatus_maps_known_codes(int status, uint expected)
{
AbCipStatusMapper.MapLibplctagStatus(status).ShouldBe(expected);
}
}

View File

@@ -0,0 +1,184 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipSubscriptionTests
{
private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
}, "drv-1", factory);
return (drv, factory);
}
[Fact]
public async Task Initial_poll_raises_OnDataChange_for_every_tag()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
new AbCipTagDefinition("Temp", "ab://10.0.0.5/1,0", "Temp", AbCipDataType.Real));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => p.TagName switch
{
"Speed" => new FakeAbCipTag(p) { Value = 1800 },
"Temp" => new FakeAbCipTag(p) { Value = 72.5f },
_ => new FakeAbCipTag(p),
};
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Speed", "Temp"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
events.Select(e => e.FullReference).ShouldContain("Speed");
events.Select(e => e.FullReference).ShouldContain("Temp");
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
[Fact]
public async Task Unchanged_value_raises_only_once()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 1800 };
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await Task.Delay(500);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBe(1);
}
[Fact]
public async Task Value_change_between_polls_raises_OnDataChange()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var tagRef = new FakeAbCipTag(new AbCipTagCreateParams("10.0.0.5", 44818, "1,0", "controllogix", "Speed", TimeSpan.FromSeconds(2))) { Value = 100 };
factory.Customise = _ => tagRef;
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
tagRef.Value = 200; // simulate PLC change
await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(2));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
events.Count.ShouldBeGreaterThanOrEqualTo(2);
events.Last().Snapshot.Value.ShouldBe(200);
}
[Fact]
public async Task Unsubscribe_halts_polling()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var tagRef = new FakeAbCipTag(new AbCipTagCreateParams("10.0.0.5", 44818, "1,0", "controllogix", "Speed", TimeSpan.FromSeconds(2))) { Value = 1 };
factory.Customise = _ => tagRef;
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
await drv.UnsubscribeAsync(handle, CancellationToken.None);
var afterUnsub = events.Count;
tagRef.Value = 999;
await Task.Delay(400);
events.Count.ShouldBe(afterUnsub);
}
[Fact]
public async Task Interval_below_100ms_is_floored()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(5), CancellationToken.None);
await Task.Delay(300);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
// Value is stable → only the initial-data push fires; the 100 ms floor keeps polls sparse enough
// that no extra event is produced against a stable value.
events.Count.ShouldBe(1);
}
[Fact]
public async Task ShutdownAsync_cancels_active_subscriptions()
{
var (drv, factory) = NewDriver(
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbCipTag(p) { Value = 1 };
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
await drv.ShutdownAsync(CancellationToken.None);
var afterShutdown = events.Count;
await Task.Delay(300);
events.Count.ShouldBe(afterShutdown);
}
[Fact]
public async Task Subscription_on_UDT_member_uses_synthesised_full_reference()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members: [new AbCipStructureMember("Speed", AbCipDataType.DInt)]),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => p.TagName == "Motor1.Speed"
? new FakeAbCipTag(p) { Value = 77 }
: new FakeAbCipTag(p);
var events = new ConcurrentQueue<DataChangeEventArgs>();
drv.OnDataChange += (_, e) => events.Enqueue(e);
var handle = await drv.SubscribeAsync(["Motor1.Speed"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
events.First().Snapshot.Value.ShouldBe(77);
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (!condition() && DateTime.UtcNow < deadline)
await Task.Delay(20);
}
}

View File

@@ -0,0 +1,146 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipTagPathTests
{
[Fact]
public void Controller_scope_single_segment()
{
var p = AbCipTagPath.TryParse("Motor1_Speed");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBeNull();
p.Segments.Count.ShouldBe(1);
p.Segments[0].Name.ShouldBe("Motor1_Speed");
p.Segments[0].Subscripts.ShouldBeEmpty();
p.BitIndex.ShouldBeNull();
p.ToLibplctagName().ShouldBe("Motor1_Speed");
}
[Fact]
public void Program_scope_parses()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.StepIndex");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Single().Name.ShouldBe("StepIndex");
p.ToLibplctagName().ShouldBe("Program:MainProgram.StepIndex");
}
[Fact]
public void Structured_member_access_splits_segments()
{
var p = AbCipTagPath.TryParse("Motor1.Speed.Setpoint");
p.ShouldNotBeNull();
p.Segments.Select(s => s.Name).ShouldBe(["Motor1", "Speed", "Setpoint"]);
p.ToLibplctagName().ShouldBe("Motor1.Speed.Setpoint");
}
[Fact]
public void Single_dim_array_subscript()
{
var p = AbCipTagPath.TryParse("Data[7]");
p.ShouldNotBeNull();
p.Segments.Single().Name.ShouldBe("Data");
p.Segments.Single().Subscripts.ShouldBe([7]);
p.ToLibplctagName().ShouldBe("Data[7]");
}
[Fact]
public void Multi_dim_array_subscript()
{
var p = AbCipTagPath.TryParse("Matrix[1,2,3]");
p.ShouldNotBeNull();
p.Segments.Single().Subscripts.ShouldBe([1, 2, 3]);
p.ToLibplctagName().ShouldBe("Matrix[1,2,3]");
}
[Fact]
public void Bit_in_dint_captured_as_bit_index()
{
var p = AbCipTagPath.TryParse("Flags.3");
p.ShouldNotBeNull();
p.Segments.Single().Name.ShouldBe("Flags");
p.BitIndex.ShouldBe(3);
p.ToLibplctagName().ShouldBe("Flags.3");
}
[Fact]
public void Bit_in_dint_after_member()
{
var p = AbCipTagPath.TryParse("Motor.Status.12");
p.ShouldNotBeNull();
p.Segments.Select(s => s.Name).ShouldBe(["Motor", "Status"]);
p.BitIndex.ShouldBe(12);
p.ToLibplctagName().ShouldBe("Motor.Status.12");
}
[Fact]
public void Bit_index_32_rejected_out_of_range()
{
// 32 exceeds the DINT bit width — treated as a member name rather than bit selector,
// which fails ident validation and returns null.
AbCipTagPath.TryParse("Flags.32").ShouldBeNull();
}
[Fact]
public void Program_scope_with_members_and_subscript_and_bit()
{
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors[0].Status.5");
p.ShouldNotBeNull();
p.ProgramScope.ShouldBe("MainProgram");
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Status"]);
p.Segments[0].Subscripts.ShouldBe([0]);
p.BitIndex.ShouldBe(5);
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors[0].Status.5");
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("Program:")] // empty scope
[InlineData("Program:MP")] // no body after scope
[InlineData("1InvalidStart")] // ident starts with digit
[InlineData("Bad Name")] // space in ident
[InlineData("Motor[]")] // empty subscript
[InlineData("Motor[-1]")] // negative subscript
[InlineData("Motor[a]")] // non-numeric subscript
[InlineData("Motor[")] // unbalanced bracket
[InlineData("Motor.")] // trailing dot
[InlineData(".Motor")] // leading dot
public void Invalid_shapes_return_null(string? input)
{
AbCipTagPath.TryParse(input).ShouldBeNull();
}
[Fact]
public void Ident_with_underscore_accepted()
{
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
}
[Fact]
public void ToLibplctagName_recomposes_round_trip()
{
var cases = new[]
{
"Motor1_Speed",
"Program:Main.Counter",
"Array[5]",
"Matrix[1,2]",
"Obj.Member.Sub",
"Flags.0",
"Program:P.Obj[2].Flags.15",
};
foreach (var c in cases)
{
var parsed = AbCipTagPath.TryParse(c);
parsed.ShouldNotBeNull(c);
parsed.ToLibplctagName().ShouldBe(c);
}
}
}

View File

@@ -0,0 +1,72 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtMemberLayoutTests
{
[Fact]
public void Packed_Atomics_Get_Natural_Alignment_Offsets()
{
// DInt (4 align) + Real (4) + Int (2) + LInt (8 — forces 2-byte pad before it)
var members = new[]
{
new AbCipStructureMember("A", AbCipDataType.DInt),
new AbCipStructureMember("B", AbCipDataType.Real),
new AbCipStructureMember("C", AbCipDataType.Int),
new AbCipStructureMember("D", AbCipDataType.LInt),
};
var offsets = AbCipUdtMemberLayout.TryBuild(members);
offsets.ShouldNotBeNull();
offsets!["A"].ShouldBe(0);
offsets["B"].ShouldBe(4);
offsets["C"].ShouldBe(8);
// cursor at 10 after Int; LInt needs 8-byte alignment → pad to 16
offsets["D"].ShouldBe(16);
}
[Fact]
public void SInt_Packed_Without_Padding()
{
var members = new[]
{
new AbCipStructureMember("X", AbCipDataType.SInt),
new AbCipStructureMember("Y", AbCipDataType.SInt),
new AbCipStructureMember("Z", AbCipDataType.SInt),
};
var offsets = AbCipUdtMemberLayout.TryBuild(members);
offsets!["X"].ShouldBe(0);
offsets["Y"].ShouldBe(1);
offsets["Z"].ShouldBe(2);
}
[Fact]
public void Returns_Null_When_Member_Is_Bool()
{
// BOOL storage in Logix UDTs is packed into a hidden host byte; declaration-only
// layout can't place it. Grouping opts out; per-tag read path handles the member.
var members = new[]
{
new AbCipStructureMember("A", AbCipDataType.DInt),
new AbCipStructureMember("Flag", AbCipDataType.Bool),
};
AbCipUdtMemberLayout.TryBuild(members).ShouldBeNull();
}
[Fact]
public void Returns_Null_When_Member_Is_String_Or_Structure()
{
AbCipUdtMemberLayout.TryBuild(
new[] { new AbCipStructureMember("Name", AbCipDataType.String) }).ShouldBeNull();
AbCipUdtMemberLayout.TryBuild(
new[] { new AbCipStructureMember("Nested", AbCipDataType.Structure) }).ShouldBeNull();
}
[Fact]
public void Returns_Null_On_Empty_Members()
{
AbCipUdtMemberLayout.TryBuild(Array.Empty<AbCipStructureMember>()).ShouldBeNull();
}
}

View File

@@ -0,0 +1,217 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtMemberTests
{
[Fact]
public async Task UDT_with_declared_members_fans_out_to_member_variables()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition(
Name: "Motor1",
DeviceHostAddress: "ab://10.0.0.5/1,0",
TagPath: "Motor1",
DataType: AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Running", AbCipDataType.Bool, Writable: false),
new AbCipStructureMember("SetPoint", AbCipDataType.Real, WriteIdempotent: true),
]),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList();
variables.ShouldContain(("Speed", "Motor1.Speed"));
variables.ShouldContain(("Running", "Motor1.Running"));
variables.ShouldContain(("SetPoint", "Motor1.SetPoint"));
builder.Variables.Single(v => v.BrowseName == "Running").Info.SecurityClass
.ShouldBe(SecurityClassification.ViewOnly);
builder.Variables.Single(v => v.BrowseName == "SetPoint").Info.WriteIdempotent
.ShouldBeTrue();
}
[Fact]
public async Task UDT_members_resolvable_for_read_via_synthesised_full_reference()
{
var factory = new FakeAbCipTagFactory
{
Customise = p => p.TagName switch
{
"Motor1.Speed" => new FakeAbCipTag(p) { Value = 1800 },
"Motor1.Running" => new FakeAbCipTag(p) { Value = true },
_ => new FakeAbCipTag(p),
},
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Running", AbCipDataType.Bool),
]),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["Motor1.Speed", "Motor1.Running"], CancellationToken.None);
snapshots[0].Value.ShouldBe(1800);
snapshots[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
snapshots[1].Value.ShouldBe(true);
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
[Fact]
public async Task UDT_member_write_routes_through_synthesised_tagpath()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("SetPoint", AbCipDataType.Real),
]),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Motor1.SetPoint", 42.5f)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
factory.Tags["Motor1.SetPoint"].Value.ShouldBe(42.5f);
}
[Fact]
public async Task UDT_member_read_write_honours_member_Writable_flag()
{
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false),
]),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Motor1.Status", 1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
}
[Fact]
public async Task Structure_tag_without_members_is_emitted_as_single_variable()
{
// Fallback path: a Structure tag with no declared Members still appears as a Variable so
// downstream configuration can address it manually. This matches the "black box" note in
// AbCipTagDefinition's docstring.
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("OpaqueUdt", "ab://10.0.0.5/1,0", "OpaqueUdt", AbCipDataType.Structure)],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.ShouldContain(v => v.BrowseName == "OpaqueUdt");
builder.Folders.ShouldNotContain(f => f.BrowseName == "OpaqueUdt");
}
[Fact]
public async Task Empty_Members_list_is_treated_like_null()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbCipTagDefinition("EmptyUdt", "ab://10.0.0.5/1,0", "E", AbCipDataType.Structure, Members: [])],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldNotContain(f => f.BrowseName == "EmptyUdt");
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
}
[Fact]
public async Task UDT_members_mixed_with_flat_tags_coexist()
{
var builder = new RecordingBuilder();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbCipTagDefinition("FlatA", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
new AbCipTagDefinition("Motor1", "ab://10.0.0.5/1,0", "Motor1", AbCipDataType.Structure,
Members:
[
new AbCipStructureMember("Speed", AbCipDataType.DInt),
]),
new AbCipTagDefinition("FlatB", "ab://10.0.0.5/1,0", "B", AbCipDataType.Real),
],
}, "drv-1");
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Variables.Select(v => v.BrowseName).ShouldBe(["FlatA", "Speed", "FlatB"], ignoreOrder: true);
}
// ---- helpers ----
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
public string FullReference => fullRef;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
}
}

View File

@@ -0,0 +1,123 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtReadPlannerTests
{
private const string Device = "ab://10.0.0.1/1,0";
[Fact]
public void Groups_Two_Members_Of_The_Same_Udt_Parent()
{
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags);
plan.Groups.Count.ShouldBe(1);
plan.Groups[0].ParentName.ShouldBe("Motor");
plan.Groups[0].Members.Count.ShouldBe(2);
plan.Fallbacks.Count.ShouldBe(0);
}
[Fact]
public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path()
{
// Reading just one member of a UDT gains nothing from grouping — one whole-UDT read
// vs one member read is equivalent cost but more client-side work. Planner demotes.
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags);
plan.Groups.ShouldBeEmpty();
plan.Fallbacks.Count.ShouldBe(1);
plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
}
[Fact]
public void Unknown_References_Fall_Back_Without_Affecting_Groups()
{
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(
new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags);
plan.Groups.Count.ShouldBe(1);
plan.Groups[0].Members.Count.ShouldBe(2);
plan.Fallbacks.Count.ShouldBe(2);
plan.Fallbacks.ShouldContain(f => f.Reference == "DoesNotExist");
plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember");
}
[Fact]
public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
{
var tags = BuildUdtTagMap(out var _);
tags = new Dictionary<string, AbCipTagDefinition>(tags, StringComparer.OrdinalIgnoreCase)
{
["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
};
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags);
plan.Groups.Count.ShouldBe(1);
plan.Fallbacks.Count.ShouldBe(1);
plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
}
[Fact]
public void Udt_With_Bool_Member_Does_Not_Group()
{
// Any BOOL in the declared members disqualifies the group — offset rules for BOOL
// can't be determined from declaration alone (Logix packs them into a hidden host
// byte). Fallback path reads each member individually.
var members = new[]
{
new AbCipStructureMember("Run", AbCipDataType.Bool),
new AbCipStructureMember("Speed", AbCipDataType.DInt),
};
var parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
Members: members);
var tags = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
{
["Motor"] = parent,
["Motor.Run"] = new("Motor.Run", Device, "Motor.Run", AbCipDataType.Bool),
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
};
var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags);
plan.Groups.ShouldBeEmpty();
plan.Fallbacks.Count.ShouldBe(2);
}
[Fact]
public void Original_Indices_Preserved_For_Out_Of_Order_Batches()
{
var tags = BuildUdtTagMap(out var _);
var plan = AbCipUdtReadPlanner.Build(
new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags);
// Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so
// ReadAsync can write decoded values back at the right output slot.
plan.Groups.ShouldHaveSingleItem();
var group = plan.Groups[0];
group.Members.ShouldContain(m => m.OriginalIndex == 1 && m.Definition.Name == "Motor.Speed");
group.Members.ShouldContain(m => m.OriginalIndex == 3 && m.Definition.Name == "Motor.Torque");
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 0 && f.Reference == "Other");
plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 2 && f.Reference == "DoesNotExist");
}
private static Dictionary<string, AbCipTagDefinition> BuildUdtTagMap(out AbCipTagDefinition parent)
{
var members = new[]
{
new AbCipStructureMember("Speed", AbCipDataType.DInt),
new AbCipStructureMember("Torque", AbCipDataType.Real),
};
parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: members);
return new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
{
["Motor"] = parent,
["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
};
}
}

View File

@@ -0,0 +1,186 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class CipSymbolObjectDecoderTests
{
/// <summary>
/// Build one Symbol Object entry in the byte layout
/// <c>instance_id(u32) symbol_type(u16) element_length(u16) array_dims(u32×3) name_len(u16) name[len] pad</c>.
/// </summary>
private static byte[] BuildEntry(
uint instanceId,
ushort symbolType,
ushort elementLength,
(uint, uint, uint) arrayDims,
string name)
{
var nameBytes = Encoding.ASCII.GetBytes(name);
var nameLen = nameBytes.Length;
var totalLen = 22 + nameLen;
if ((totalLen & 1) != 0) totalLen++; // pad to even
var buf = new byte[totalLen];
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(0), instanceId);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(4), symbolType);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(6), elementLength);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), arrayDims.Item1);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(12), arrayDims.Item2);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(16), arrayDims.Item3);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(20), (ushort)nameLen);
Buffer.BlockCopy(nameBytes, 0, buf, 22, nameLen);
return buf;
}
private static byte[] Concat(params byte[][] chunks)
{
var total = chunks.Sum(c => c.Length);
var result = new byte[total];
var pos = 0;
foreach (var c in chunks)
{
Buffer.BlockCopy(c, 0, result, pos, c.Length);
pos += c.Length;
}
return result;
}
[Fact]
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
{
var bytes = BuildEntry(
instanceId: 42,
symbolType: 0xC4,
elementLength: 4,
arrayDims: (0, 0, 0),
name: "Counter");
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
tags.Count.ShouldBe(1);
tags[0].Name.ShouldBe("Counter");
tags[0].ProgramScope.ShouldBeNull();
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
tags[0].IsSystemTag.ShouldBeFalse();
}
[Theory]
[InlineData((byte)0xC1, AbCipDataType.Bool)]
[InlineData((byte)0xC2, AbCipDataType.SInt)]
[InlineData((byte)0xC3, AbCipDataType.Int)]
[InlineData((byte)0xC4, AbCipDataType.DInt)]
[InlineData((byte)0xC5, AbCipDataType.LInt)]
[InlineData((byte)0xC6, AbCipDataType.USInt)]
[InlineData((byte)0xC7, AbCipDataType.UInt)]
[InlineData((byte)0xC8, AbCipDataType.UDInt)]
[InlineData((byte)0xC9, AbCipDataType.ULInt)]
[InlineData((byte)0xCA, AbCipDataType.Real)]
[InlineData((byte)0xCB, AbCipDataType.LReal)]
[InlineData((byte)0xD0, AbCipDataType.String)]
public void Every_known_atomic_type_code_maps_to_correct_AbCipDataType(byte typeCode, AbCipDataType expected)
{
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
}
[Fact]
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
{
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
}
[Fact]
public void Struct_flag_overrides_type_code_and_yields_Structure()
{
// 0x8000 (struct) + 0x1234 (template instance id in lower 12 bits; uses 0x234)
var bytes = BuildEntry(
instanceId: 5,
symbolType: 0x8000 | 0x0234,
elementLength: 16,
arrayDims: (0, 0, 0),
name: "Motor1");
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
tag.DataType.ShouldBe(AbCipDataType.Structure);
}
[Fact]
public void System_flag_surfaces_as_IsSystemTag_true()
{
var bytes = BuildEntry(
instanceId: 99,
symbolType: 0x1000 | 0xC4, // system flag + DINT
elementLength: 4,
arrayDims: (0, 0, 0),
name: "__Reserved_1");
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
tag.IsSystemTag.ShouldBeTrue();
tag.DataType.ShouldBe(AbCipDataType.DInt);
}
[Fact]
public void Program_scope_name_splits_prefix_into_ProgramScope()
{
var bytes = BuildEntry(
instanceId: 1,
symbolType: 0xC4,
elementLength: 4,
arrayDims: (0, 0, 0),
name: "Program:MainProgram.StepIndex");
var tag = CipSymbolObjectDecoder.Decode(bytes).Single();
tag.ProgramScope.ShouldBe("MainProgram");
tag.Name.ShouldBe("StepIndex");
}
[Fact]
public void Multiple_entries_decode_in_wire_order_with_even_padding()
{
// Name "Abc" is 3 bytes — triggers the even-pad branch between entries.
var bytes = Concat(
BuildEntry(1, 0xC4, 4, (0, 0, 0), "Abc"), // DINT named "Abc" (3-byte name, pads to 4)
BuildEntry(2, 0xCA, 4, (0, 0, 0), "Pi")); // REAL named "Pi"
var tags = CipSymbolObjectDecoder.Decode(bytes).ToList();
tags.Count.ShouldBe(2);
tags[0].Name.ShouldBe("Abc");
tags[0].DataType.ShouldBe(AbCipDataType.DInt);
tags[1].Name.ShouldBe("Pi");
tags[1].DataType.ShouldBe(AbCipDataType.Real);
}
[Fact]
public void Truncated_buffer_stops_decoding_gracefully()
{
var full = BuildEntry(7, 0xC4, 4, (0, 0, 0), "Counter");
// Deliberately chop off the last 5 bytes — decoder should bail cleanly, not throw.
var truncated = full.Take(full.Length - 5).ToArray();
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
}
[Fact]
public void Empty_buffer_yields_no_tags()
{
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
}
[Theory]
[InlineData("Counter", null, "Counter")]
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
[InlineData("Program:MyProg.a.b.c", "MyProg", "a.b.c")]
[InlineData("Program:", null, "Program:")] // malformed — no dot
[InlineData("Program:OnlyProg", null, "Program:OnlyProg")]
[InlineData("Motor.Status.Running", null, "Motor.Status.Running")]
public void SplitProgramScope_handles_every_shape(string input, string? expectedScope, string expectedName)
{
var (scope, name) = CipSymbolObjectDecoder.SplitProgramScope(input);
scope.ShouldBe(expectedScope);
name.ShouldBe(expectedName);
}
}

View File

@@ -0,0 +1,180 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class CipTemplateObjectDecoderTests
{
/// <summary>
/// Construct a Template Object blob — header + member blocks + semicolon-delimited
/// strings (UDT name first, then member names).
/// </summary>
private static byte[] BuildTemplate(
string udtName,
uint instanceSize,
params (string name, ushort info, ushort arraySize, uint offset)[] members)
{
var memberCount = (ushort)members.Length;
var headerSize = 12;
var memberBlockSize = 8;
var blocksSize = memberBlockSize * members.Length;
var stringsBuf = new MemoryStream();
void AppendString(string s)
{
var bytes = Encoding.ASCII.GetBytes(s + ";\0");
stringsBuf.Write(bytes, 0, bytes.Length);
}
AppendString(udtName);
foreach (var m in members) AppendString(m.name);
var strings = stringsBuf.ToArray();
var buf = new byte[headerSize + blocksSize + strings.Length];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(8), 0);
for (var i = 0; i < members.Length; i++)
{
var o = headerSize + (i * memberBlockSize);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arraySize);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].offset);
}
Buffer.BlockCopy(strings, 0, buf, headerSize + blocksSize, strings.Length);
return buf;
}
[Fact]
public void Simple_two_member_UDT_decodes_correctly()
{
var bytes = BuildTemplate("MotorUdt", instanceSize: 8,
("Speed", info: 0xC4, arraySize: 0, offset: 0), // DINT at offset 0
("Enabled", info: 0xC1, arraySize: 0, offset: 4)); // BOOL at offset 4
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.TypeName.ShouldBe("MotorUdt");
shape.TotalSize.ShouldBe(8);
shape.Members.Count.ShouldBe(2);
shape.Members[0].Name.ShouldBe("Speed");
shape.Members[0].DataType.ShouldBe(AbCipDataType.DInt);
shape.Members[0].Offset.ShouldBe(0);
shape.Members[0].ArrayLength.ShouldBe(1);
shape.Members[1].Name.ShouldBe("Enabled");
shape.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
shape.Members[1].Offset.ShouldBe(4);
}
[Fact]
public void Struct_member_flag_surfaces_Structure_type()
{
var bytes = BuildTemplate("ContainerUdt", instanceSize: 32,
("InnerStruct", info: 0x8042, arraySize: 0, offset: 0)); // struct flag + template-id 0x42
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
[Fact]
public void Array_member_carries_non_one_ArrayLength()
{
var bytes = BuildTemplate("ArrayUdt", instanceSize: 40,
("Values", info: 0xC4, arraySize: 10, offset: 0));
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.Members.Single().ArrayLength.ShouldBe(10);
}
[Fact]
public void Multiple_atomic_types_preserve_offsets_and_types()
{
var bytes = BuildTemplate("MixedUdt", instanceSize: 24,
("A", 0xC1, 0, 0), // BOOL
("B", 0xC2, 0, 1), // SINT
("C", 0xC3, 0, 2), // INT
("D", 0xC4, 0, 4), // DINT
("E", 0xCA, 0, 8), // REAL
("F", 0xCB, 0, 16)); // LREAL
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.Members.Count.ShouldBe(6);
shape.Members.Select(m => m.DataType).ShouldBe(
[AbCipDataType.Bool, AbCipDataType.SInt, AbCipDataType.Int,
AbCipDataType.DInt, AbCipDataType.Real, AbCipDataType.LReal]);
shape.Members.Select(m => m.Offset).ShouldBe([0, 1, 2, 4, 8, 16]);
}
[Fact]
public void Unknown_atomic_type_code_falls_back_to_Structure()
{
var bytes = BuildTemplate("WeirdUdt", instanceSize: 4,
("Unknown", info: 0xFF, 0, 0));
var shape = CipTemplateObjectDecoder.Decode(bytes);
shape.ShouldNotBeNull();
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
[Fact]
public void Zero_member_count_returns_null()
{
var buf = new byte[12];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), 0);
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
}
[Fact]
public void Short_buffer_returns_null()
{
CipTemplateObjectDecoder.Decode([0x01, 0x00]).ShouldBeNull(); // only 2 bytes — less than header
}
[Fact]
public void Missing_member_name_surfaces_placeholder()
{
// Header says 3 members but strings list has only UDT name + 2 member names.
var memberCount = (ushort)3;
var buf = new byte[12 + 8 * 3];
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), memberCount);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), 12);
for (var i = 0; i < 3; i++)
{
var o = 12 + i * 8;
BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), 0xC4);
BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), (uint)(i * 4));
}
// strings: only UDT + 2 members, missing the third.
var strings = Encoding.ASCII.GetBytes("MyUdt;\0A;\0B;\0");
var combined = buf.Concat(strings).ToArray();
var shape = CipTemplateObjectDecoder.Decode(combined);
shape.ShouldNotBeNull();
shape.Members.Count.ShouldBe(3);
shape.Members[2].Name.ShouldBe("<member_2>");
}
[Theory]
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
[InlineData("Only;\0", new[] { "Only" })]
[InlineData(";\0", new string[] { })] // empty
[InlineData("", new string[] { })]
public void ParseSemicolonTerminatedStrings_handles_shapes(string input, string[] expected)
{
var bytes = Encoding.ASCII.GetBytes(input);
var result = CipTemplateObjectDecoder.ParseSemicolonTerminatedStrings(bytes);
result.ShouldBe(expected);
}
}

View File

@@ -0,0 +1,82 @@
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Test fake for <see cref="IAbCipTagRuntime"/>. Stores the mock PLC value in
/// <see cref="Value"/> + returns it from <see cref="DecodeValue"/>. Use
/// <see cref="Status"/> to simulate libplctag error codes,
/// <see cref="ThrowOnInitialize"/> / <see cref="ThrowOnRead"/> to simulate exceptions.
/// </summary>
internal class FakeAbCipTag : IAbCipTagRuntime
{
public AbCipTagCreateParams CreationParams { get; }
public object? Value { get; set; }
public int Status { get; set; }
public bool ThrowOnInitialize { get; set; }
public bool ThrowOnRead { get; set; }
public Exception? Exception { get; set; }
public int InitializeCount { get; private set; }
public int ReadCount { get; private set; }
public int WriteCount { get; private set; }
public bool Disposed { get; private set; }
public FakeAbCipTag(AbCipTagCreateParams createParams) => CreationParams = createParams;
public virtual Task InitializeAsync(CancellationToken cancellationToken)
{
InitializeCount++;
if (ThrowOnInitialize) throw Exception ?? new InvalidOperationException("fake initialize failure");
return Task.CompletedTask;
}
public virtual Task ReadAsync(CancellationToken cancellationToken)
{
ReadCount++;
if (ThrowOnRead) throw Exception ?? new InvalidOperationException("fake read failure");
return Task.CompletedTask;
}
public virtual Task WriteAsync(CancellationToken cancellationToken)
{
WriteCount++;
return Task.CompletedTask;
}
public virtual int GetStatus() => Status;
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
/// <summary>
/// Task #194 whole-UDT read support. Tests drive multi-member decoding by setting
/// <see cref="ValuesByOffset"/> — keyed by member byte offset — before invoking
/// <see cref="AbCipDriver.ReadAsync"/>. Falls back to <see cref="Value"/> when the
/// offset is zero or unmapped so existing tests that never set the offset map keep
/// working unchanged.
/// </summary>
public Dictionary<int, object?> ValuesByOffset { get; } = new();
public virtual object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex)
{
if (ValuesByOffset.TryGetValue(offset, out var v)) return v;
return offset == 0 ? Value : null;
}
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
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
{
public Dictionary<string, FakeAbCipTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
public Func<AbCipTagCreateParams, FakeAbCipTag>? Customise { get; set; }
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams)
{
var fake = Customise?.Invoke(createParams) ?? new FakeAbCipTag(createParams);
Tags[createParams.TagName] = fake;
return fake;
}
}

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" Version="1.1.0"/>
<PackageReference Include="Shouldly" Version="4.3.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
</ItemGroup>
<ItemGroup>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
</ItemGroup>
</Project>