f85fc8fbeb
- 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
375 lines
18 KiB
C#
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><= 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) { }
|
|
}
|
|
}
|
|
}
|