Replace the NotSupportedException at AdsTwinCATClient.WriteValueAsync for bit-indexed BOOL writes with a read-modify-write path: 1. Strip the trailing .N selector from the symbol path. 2. Read the parent as UDINT. 3. Set or clear bit N via the standard mask. 4. Write the parent back. Concurrent bit writers against the same parent serialise through a per-parent SemaphoreSlim cached in a ConcurrentDictionary (never removed — bounded by writable-bit-tag cardinality). Mirrors the AbCip / Modbus / FOCAS bit-RMW pattern shipped in #181 pass 1. The path-stripping (TryGetParentSymbolPath) and mask helper (ApplyBit) are exposed as internal statics so tests can pin the pure logic without needing a real ADS target. The FakeTwinCATClient mirrors the same RMW semantics so driver-level round-trip tests assert the parent-word state. Closes #307
106 lines
4.3 KiB
C#
106 lines
4.3 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
|
|
|
[Trait("Category", "Unit")]
|
|
public sealed class TwinCATBitWriteTests
|
|
{
|
|
// ---- Helper unit tests ----
|
|
|
|
[Theory]
|
|
[InlineData("Flags.3", "Flags")]
|
|
[InlineData("MAIN.Status.7", "MAIN.Status")]
|
|
[InlineData("GVL.Motors[0].Status.5", "GVL.Motors[0].Status")]
|
|
public void TryGetParentSymbolPath_strips_trailing_bit_selector(string input, string expected)
|
|
{
|
|
AdsTwinCATClient.TryGetParentSymbolPath(input).ShouldBe(expected);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Counter")] // single segment — no parent
|
|
[InlineData(".Bad")] // leading dot
|
|
public void TryGetParentSymbolPath_returns_null_for_pathless(string input)
|
|
{
|
|
AdsTwinCATClient.TryGetParentSymbolPath(input).ShouldBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0u, 0, true, 0x0000_0001u)]
|
|
[InlineData(0u, 31, true, 0x8000_0000u)]
|
|
[InlineData(0xFFFF_FFFFu, 0, false, 0xFFFF_FFFEu)]
|
|
[InlineData(0xFFFF_FFFFu, 31, false, 0x7FFF_FFFFu)]
|
|
[InlineData(0x0000_0008u, 3, true, 0x0000_0008u)] // already set — idempotent
|
|
[InlineData(0x0000_0000u, 3, false, 0x0000_0000u)] // already clear — idempotent
|
|
public void ApplyBit_sets_or_clears_bit(uint word, int bit, bool setBit, uint expected)
|
|
{
|
|
AdsTwinCATClient.ApplyBit(word, bit, setBit).ShouldBe(expected);
|
|
}
|
|
|
|
// ---- Driver-level round-trip ----
|
|
|
|
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags)
|
|
{
|
|
var factory = new FakeTwinCATClientFactory();
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
|
|
Tags = tags,
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
}, "drv-1", factory);
|
|
return (drv, factory);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bit_write_sets_bit_in_parent_word()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new TwinCATTagDefinition("FlagBit3", "ads://5.23.91.23.1.1:851", "Flags.3", TwinCATDataType.Bool));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
// Parent word starts at 0.
|
|
factory.Customise = () => new FakeTwinCATClient { Values = { ["Flags"] = 0u } };
|
|
|
|
var results = await drv.WriteAsync(
|
|
[new Core.Abstractions.WriteRequest("FlagBit3", true)], CancellationToken.None);
|
|
|
|
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
// Parent should now have bit 3 set.
|
|
Convert.ToUInt32(factory.Clients[0].Values["Flags"]!).ShouldBe(0x0000_0008u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bit_write_clears_bit_without_disturbing_neighbours()
|
|
{
|
|
var (drv, factory) = NewDriver(
|
|
new TwinCATTagDefinition("FlagBit3", "ads://5.23.91.23.1.1:851", "Flags.3", TwinCATDataType.Bool));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
// Parent word has bits 0, 3, 7 set initially.
|
|
factory.Customise = () => new FakeTwinCATClient { Values = { ["Flags"] = 0x0000_0089u } };
|
|
|
|
var results = await drv.WriteAsync(
|
|
[new Core.Abstractions.WriteRequest("FlagBit3", false)], CancellationToken.None);
|
|
|
|
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
// Bit 3 cleared; bits 0 and 7 untouched.
|
|
Convert.ToUInt32(factory.Clients[0].Values["Flags"]!).ShouldBe(0x0000_0081u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Bit_write_does_not_throw_NotSupported()
|
|
{
|
|
// Regression: AdsTwinCATClient previously threw NotSupportedException for bit writes.
|
|
var (drv, factory) = NewDriver(
|
|
new TwinCATTagDefinition("Bit", "ads://5.23.91.23.1.1:851", "GVL.Word.0", TwinCATDataType.Bool));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Word"] = 0u } };
|
|
|
|
var results = await drv.WriteAsync(
|
|
[new Core.Abstractions.WriteRequest("Bit", true)], CancellationToken.None);
|
|
|
|
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
// Status should be Good, not BadNotSupported (which is what the catch block produced).
|
|
results.Single().StatusCode.ShouldNotBe(TwinCATStatusMapper.BadNotSupported);
|
|
}
|
|
}
|