chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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 1–1000 — 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.");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) { } }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) { } }
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user