Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitWriteTests.cs
Joseph Doherty fcf89618cd Auto: twincat-1.3 — bit-indexed BOOL writes (RMW)
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
2026-04-25 17:22:59 -04:00

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);
}
}