Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDiscoveredMemberWriteTests.cs
T
Joseph Doherty f85fc8fbeb fix(abcip): #611 review round 1 — native keeps BOOL type distinction, WriteWidthRefusal tests, doc caveats
- L2: WriteWidthRefusal leaves a BOOL over a wider value to a runtime that sees the CIP
  type (native): mistyped integer member -> BadTypeMismatch (as its read), 0xD3 BOOL-array
  DWORD -> BadNotSupported; libplctag keeps BadNotSupported for both (size only).
- L4: direct theory over WriteWidthRefusal (1/2/4/8 widths incl LINT/ULINT/LREAL, String/Dt,
  unknown size) + array/bit-selector exemptions with a non-vacuous control.
- L1: CLAUDE.md member/BIT-member writes are ab_server-proven only.
- L3: patch 0003 blind spots documented (Docker README, AbServer-Test-Fixture.md); nits.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 04:31:45 -04:00

375 lines
18 KiB
C#

using System.Runtime.CompilerServices;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Gitea #611 — writes to controller-discovered UDT members (item 2) and the whole-UDT refusal (item 3).
/// A discovered member becomes writable the way every v3 tag does: browse-commit authors it as a raw tag
/// (<c>tagPath</c> = the member path, <c>dataType</c> = the template's atomic type, AccessLevel Read — see
/// <c>RawBrowseCommitMapperTests.A_discovered_AbCip_UDT_member_commits_read_only_with_its_member_path_and_type</c>)
/// and the operator flips it to ReadWrite. These tests start from what discovery emits and assert what the
/// driver does with that tag.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipDiscoveredMemberWriteTests
{
private const string Device = "ab://10.0.0.5/1,0";
/// <summary>
/// Discovery → commit → write, end to end at the driver seam: every atomic leaf of a browsed UDT
/// (top-level, nested, BIT) committed as a raw tag writes through a runtime named by its member path,
/// encoded as its browsed type.
/// </summary>
[Fact]
public async Task Every_browsed_member_leaf_committed_as_a_raw_tag_writes_to_its_own_member_path()
{
var leaves = await DiscoverLeavesAsync();
leaves.Select(l => l.FullName).ShouldBe(
["Seg.ID", "Seg.Enable", "Seg.RampTime", "Seg.Sub.Code"], ignoreOrder: true);
var factory = new FakeAbCipTagFactory();
var drv = await StartAsync(factory, [.. leaves.Select(l => Committed(l.FullName, l.NativeDataType!))]);
var values = new Dictionary<string, object>
{
["Seg.ID"] = 42, ["Seg.Enable"] = true, ["Seg.RampTime"] = 1.5f, ["Seg.Sub.Code"] = (short)7,
};
var results = await drv.WriteAsync(
[.. values.Select(kv => new WriteRequest(kv.Key, kv.Value))], CancellationToken.None);
results.Select(r => r.StatusCode).ShouldAllBe(s => s == AbCipStatusMapper.Good);
foreach (var (path, value) in values)
{
var tag = factory.Tags[path];
tag.CreationParams.TagName.ShouldBe(path); // the member path itself — never its UDT, never a hidden host
tag.Value.ShouldBe(value);
tag.WriteCount.ShouldBe(1);
}
factory.Tags["Seg.Enable"].CreationParams.AuthoredType.ShouldBe(AbCipDataType.Bool);
factory.Tags["Seg.Sub.Code"].CreationParams.AuthoredType.ShouldBe(AbCipDataType.Int);
}
/// <summary>The operator's <c>writable:false</c> on a committed member is honoured — nothing is created or sent.</summary>
[Fact]
public async Task A_member_authored_not_writable_is_BadNotWritable()
{
var factory = new FakeAbCipTagFactory();
var drv = await StartAsync(factory, Committed("Seg.ID", "DInt", writable: false));
(await drv.WriteAsync([new WriteRequest("Seg.ID", 1)], CancellationToken.None))
.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
factory.Tags.ShouldNotContainKey("Seg.ID");
}
/// <summary>
/// An INT authored over a member the controller holds as 4 bytes: libplctag would patch the low two bytes
/// of its read buffer and send all four. Refused BadTypeMismatch before anything is sent, handle kept,
/// health untouched.
/// </summary>
[Fact]
public async Task A_member_authored_narrower_than_the_controller_value_is_refused_before_sending()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 4, Value = 0 } };
// The mistyped tag cannot prime health itself — its READ is refused for the same width mismatch.
var drv = await StartAsync(factory, Committed("Seg.ID", "Int"), Committed("Seg.RampTime", "Real"));
await PrimeHealthAsync(drv, "Seg.RampTime");
var health = drv.GetHealth();
(await drv.WriteAsync([new WriteRequest("Seg.ID", (short)5)], CancellationToken.None))
.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
factory.Tags["Seg.ID"].WriteCount.ShouldBe(0);
factory.Tags["Seg.ID"].Disposed.ShouldBeFalse();
drv.GetHealth().ShouldBe(health);
}
/// <summary>
/// A BOOL authored over a 4-byte value (a packed BOOL-array DWORD, or a mis-typed DINT member): writing it
/// would rewrite all 32 bits. Refused BadNotSupported — the native runtime's refusal — before sending.
/// </summary>
[Fact]
public async Task A_BOOL_over_a_wider_value_is_refused_NotSupported_before_sending()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 4, Value = 0 } };
var drv = await StartAsync(factory, Committed("Seg.Flags", "Bool"));
await PrimeHealthAsync(drv, "Seg.Flags");
var health = drv.GetHealth();
(await drv.WriteAsync([new WriteRequest("Seg.Flags", true)], CancellationToken.None))
.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
factory.Tags["Seg.Flags"].WriteCount.ShouldBe(0);
drv.GetHealth().ShouldBe(health);
}
/// <summary>A BIT member reads back as one byte (type 0xC1), so a BOOL write to it goes out.</summary>
[Fact]
public async Task A_BOOL_member_held_as_one_byte_writes()
{
var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = 1, Value = false } };
var drv = await StartAsync(factory, Committed("Seg.Enable", "Bool"));
(await drv.WriteAsync([new WriteRequest("Seg.Enable", true)], CancellationToken.None))
.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
factory.Tags["Seg.Enable"].WriteCount.ShouldBe(1);
}
/// <summary>Gitea #611 item 3 — a whole-UDT write is refused by decision: no runtime, nothing sent, no health change.</summary>
[Fact]
public async Task A_whole_UDT_write_is_refused_NotSupported_without_a_runtime_or_a_health_change()
{
var factory = new FakeAbCipTagFactory();
var drv = await StartAsync(factory, Committed("Seg", "Structure"), Committed("Seg.ID", "DInt"));
await PrimeHealthAsync(drv, "Seg.ID");
var health = drv.GetHealth();
(await drv.WriteAsync([new WriteRequest("Seg", new byte[8])], CancellationToken.None))
.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
factory.Tags.ShouldNotContainKey("Seg");
drv.GetHealth().ShouldBe(health);
}
/// <summary>
/// A runtime's NotSupportedException is a local refusal the device never saw — BadNotSupported with the
/// handle kept and health untouched (it used to latch Degraded).
/// </summary>
[Fact]
public async Task A_runtime_NotSupported_refusal_leaves_health_alone_and_keeps_the_handle()
{
var factory = new FakeAbCipTagFactory { Customise = p => new RefusingEncodeTag(p) { Value = 0 } };
var drv = await StartAsync(factory, Committed("Seg.ID", "DInt"));
await PrimeHealthAsync(drv, "Seg.ID");
var health = drv.GetHealth();
(await drv.WriteAsync([new WriteRequest("Seg.ID", 1)], CancellationToken.None))
.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
drv.GetHealth().ShouldBe(health);
factory.Tags["Seg.ID"].Disposed.ShouldBeFalse();
factory.Tags["Seg.ID"].WriteCount.ShouldBe(0);
}
/// <summary>
/// Review L4 — <see cref="AbCipDriver.WriteWidthRefusal"/> directly, with a real size so the check runs:
/// every atomic width (1/2/4/8, incl. LINT/ULINT/LREAL) passes at its own size and is refused at another;
/// arrays, <c>Tag.N</c> bit selectors, STRING/DT and an unknown size (<c>&lt;= 0</c>) are never judged.
/// </summary>
[Theory]
// matching widths write
[InlineData(AbCipDataType.Bool, 1, null)]
[InlineData(AbCipDataType.SInt, 1, null)]
[InlineData(AbCipDataType.USInt, 1, null)]
[InlineData(AbCipDataType.Int, 2, null)]
[InlineData(AbCipDataType.UInt, 2, null)]
[InlineData(AbCipDataType.DInt, 4, null)]
[InlineData(AbCipDataType.UDInt, 4, null)]
[InlineData(AbCipDataType.Real, 4, null)]
[InlineData(AbCipDataType.LInt, 8, null)]
[InlineData(AbCipDataType.ULInt, 8, null)]
[InlineData(AbCipDataType.LReal, 8, null)]
// mismatches are refused
[InlineData(AbCipDataType.LInt, 4, AbCipStatusMapper.BadTypeMismatch)]
[InlineData(AbCipDataType.LReal, 4, AbCipStatusMapper.BadTypeMismatch)]
[InlineData(AbCipDataType.ULInt, 2, AbCipStatusMapper.BadTypeMismatch)]
[InlineData(AbCipDataType.Real, 8, AbCipStatusMapper.BadTypeMismatch)]
[InlineData(AbCipDataType.Int, 4, AbCipStatusMapper.BadTypeMismatch)]
[InlineData(AbCipDataType.SInt, 2, AbCipStatusMapper.BadTypeMismatch)]
[InlineData(AbCipDataType.Bool, 4, AbCipStatusMapper.BadNotSupported)]
// types with no fixed encoded width are never judged
[InlineData(AbCipDataType.String, 88, null)]
[InlineData(AbCipDataType.String, 4, null)]
[InlineData(AbCipDataType.Dt, 8, null)]
// unknown size is never judged
[InlineData(AbCipDataType.DInt, 0, null)]
[InlineData(AbCipDataType.DInt, -1, null)]
public void WriteWidthRefusal_judges_scalar_widths_only(AbCipDataType type, int size, uint? expected)
{
var def = new AbCipTagDefinition("T", Device, "Seg.X", type);
AbCipDriver.WriteWidthRefusal(def, bitIndex: null, size).ShouldBe(expected);
}
/// <summary>Review L4 — array tags and bit selectors are exempt even at a size that would otherwise refuse.</summary>
[Fact]
public void WriteWidthRefusal_exempts_arrays_and_bit_selectors()
{
// Control: the same scalar at the same size IS refused, so the exemptions below are not vacuous.
AbCipDriver.WriteWidthRefusal(new AbCipTagDefinition("T", Device, "Seg.X", AbCipDataType.Int), null, 4)
.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
AbCipDriver.WriteWidthRefusal(
new AbCipTagDefinition("T", Device, "Seg.X", AbCipDataType.Int, ElementCount: 4, IsArray: true), null, 4)
.ShouldBeNull();
AbCipDriver.WriteWidthRefusal(
new AbCipTagDefinition("T", Device, "Seg.X", AbCipDataType.Int, ElementCount: 2), null, 4)
.ShouldBeNull();
AbCipDriver.WriteWidthRefusal(
new AbCipTagDefinition("T", Device, "Flags.3", AbCipDataType.Bool), bitIndex: 3, size: 4)
.ShouldBeNull();
AbCipDriver.WriteWidthRefusal(
new AbCipTagDefinition("T", Device, "Word.3", AbCipDataType.Int), bitIndex: 3, size: 4)
.ShouldBeNull();
}
/// <summary>
/// Review L2 — a runtime that types BOOL writes itself (native) is left to judge a BOOL over a wider value;
/// only libplctag, which sees a size and nothing else, gets the pre-check's BadNotSupported.
/// </summary>
[Fact]
public void WriteWidthRefusal_leaves_a_wide_BOOL_to_a_runtime_that_sees_the_CIP_type()
{
var def = new AbCipTagDefinition("T", Device, "Seg.Flag", AbCipDataType.Bool);
AbCipDriver.WriteWidthRefusal(def, null, 4, runtimeTypesBool: false).ShouldBe(AbCipStatusMapper.BadNotSupported);
AbCipDriver.WriteWidthRefusal(def, null, 4, runtimeTypesBool: true).ShouldBeNull();
// Non-BOOL mismatches are still refused up front on every runtime.
AbCipDriver.WriteWidthRefusal(new AbCipTagDefinition("T", Device, "Seg.X", AbCipDataType.Int), null, 4,
runtimeTypesBool: true).ShouldBe(AbCipStatusMapper.BadTypeMismatch);
}
/// <summary>
/// Review L2, end to end on the native runtime: a BOOL authored over a mistyped DINT member writes
/// BadTypeMismatch (the same verdict as its read); a BOOL over a packed BOOL-array DWORD writes
/// BadNotSupported. Neither sends a write; health is untouched.
/// </summary>
[Theory]
[InlineData("C400", AbCipStatusMapper.BadTypeMismatch)] // DINT member mistyped as Bool
[InlineData("D300", AbCipStatusMapper.BadNotSupported)] // packed BOOL-array DWORD
public async Task A_native_BOOL_over_a_wider_value_keeps_its_type_distinction(string typeHex, uint expected)
{
var channels = new List<FakeCipChannel>();
var factory = new NativeFactory(p =>
{
var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000000" + typeHex + "00000000")) };
lock (channels) channels.Add(ch);
return NativeAbCipTagRuntime.ForTest(ch, p);
});
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = [Committed("Seg.Flag", "Bool")],
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-native", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var health = drv.GetHealth();
(await drv.WriteAsync([new WriteRequest("Seg.Flag", true)], CancellationToken.None))
.Single().StatusCode.ShouldBe(expected);
channels.ShouldHaveSingleItem().Sent.ShouldAllBe(req => req[0] != 0x4D); // no Write Tag request
drv.GetHealth().ShouldBe(health);
}
private sealed class NativeFactory(Func<AbCipTagCreateParams, IAbCipTagRuntime> create) : IAbCipTagFactory
{
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams);
}
// ---- helpers ---------------------------------------------------------------------------------------
/// <summary>Browses a UDT shaped like the Z-35162 press's UDT_TemperatureProfile_Segment (+ a nested struct).</summary>
private static async Task<List<DriverAttributeInfo>> DiscoverLeavesAsync()
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
EnableControllerBrowse = true,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-browse", enumeratorFactory: new OneTagEnumeratorFactory(
new AbCipDiscoveredTag("Seg", null, AbCipDataType.Structure, ReadOnly: false)));
await drv.InitializeAsync("{}", CancellationToken.None);
drv.SeedDiscoveredUdtShapeForTest(Device, "Seg", new AbCipUdtShape("UDT_Segment", 24,
[
new AbCipUdtMember("ID", 0, AbCipDataType.DInt, ArrayLength: 1),
new AbCipUdtMember("ZZZZZZZZZZUDT_Segm1", 4, AbCipDataType.SInt, ArrayLength: 1), // hidden BIT host
new AbCipUdtMember("Enable", 4, AbCipDataType.Bool, ArrayLength: 1),
new AbCipUdtMember("RampTime", 8, AbCipDataType.Real, ArrayLength: 1),
new AbCipUdtMember("Sub", 12, AbCipDataType.Structure, ArrayLength: 1),
]));
drv.SeedDiscoveredUdtShapeForTest(Device, "Sub", new AbCipUdtShape("UDT_Sub", 4,
[new AbCipUdtMember("Code", 0, AbCipDataType.Int, ArrayLength: 1)]));
var builder = new LeafRecorder();
await drv.DiscoverAsync(builder, CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
return builder.Leaves;
}
/// <summary>A raw tag as <c>RawBrowseCommitMapper</c> commits a discovered leaf (plus an optional writable key).</summary>
private static RawTagEntry Committed(string tagPath, string dataType, bool? writable = null) =>
new(tagPath,
writable is { } w
? $$"""{"tagPath":"{{tagPath}}","dataType":"{{dataType}}","writable":{{(w ? "true" : "false")}}}"""
: $$"""{"tagPath":"{{tagPath}}","dataType":"{{dataType}}"}""",
WriteIdempotent: false,
DeviceName: Device);
private static async Task<AbCipDriver> StartAsync(FakeAbCipTagFactory factory, params RawTagEntry[] tags)
{
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Device)],
RawTags = tags,
Probe = new AbCipProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
return drv;
}
/// <summary>One good read so health holds a concrete Healthy snapshot the refusal must leave untouched.</summary>
private static async Task PrimeHealthAsync(AbCipDriver drv, string reference) =>
(await drv.ReadAsync([reference], CancellationToken.None)).Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
private sealed class RefusingEncodeTag(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
throw new NotSupportedException("refused locally");
}
private sealed class OneTagEnumeratorFactory(params AbCipDiscoveredTag[] tags) : IAbCipTagEnumeratorFactory
{
public IAbCipTagEnumerator Create() => new Enumerator(tags);
private sealed class Enumerator(AbCipDiscoveredTag[] tags) : IAbCipTagEnumerator
{
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask;
foreach (var t in tags) yield return t;
}
public void Dispose() { }
}
}
private sealed class LeafRecorder : IAddressSpaceBuilder
{
public List<DriverAttributeInfo> Leaves { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{
Leaves.Add(info);
return new Handle(info.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
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) { }
}
}
}