Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBoolInDIntRmwTests.cs
Joseph Doherty 00a428c444 RMW pass 2 — AbCip BOOL-within-DINT + AbLegacy bit-within-word. Closes task #181. AbCip — AbCipDriver.WriteAsync now detects BOOL writes with a bit index + routes them through WriteBitInDIntAsync: strip the .N suffix to form the parent DINT tag path (via AbCipTagPath with BitIndex=null + ToLibplctagName), get/create a cached parent IAbCipTagRuntime via EnsureParentRuntimeAsync (distinct from the bit-selector tag runtime so read + write target the DINT directly), acquire a per-parent-name SemaphoreSlim, Read → Convert.ToInt32 the current DINT → (current | 1<<bit) or (current & ~(1<<bit)) → Write via EncodeValue(DInt, updated). Per-parent lock prevents concurrent writers to the same DINT from losing updates — parallels Modbus + FOCAS pass 1. DeviceState gains ParentRuntimes dict + GetRmwLock helper + _rmwLocks ConcurrentDictionary. DisposeHandles now walks ParentRuntimes too. LibplctagTagRuntime.EncodeValue's BOOL-with-bitIndex branch stays as a defensive throw (message updated to point at the new driver-level dispatch) so an accidental bypass fails loudly rather than silently clobbering the whole DINT. AbLegacy — identical pattern for PCCC N-file bit writes. AbLegacyDriver.WriteAsync detects Bit with bitIndex + PMC letter not in {B, I, O} (B-file + I/O use their own bit-addressable semantics so don't RMW at N-file word level), routes through WriteBitInWordAsync which uses Int16 for the parent word, creates + caches a parent runtime with the suffix-stripped N7:0 address, acquires per-parent lock, RMW. DeviceState extended the same way as AbCip (ParentRuntimes + GetRmwLock). LibplctagLegacyTagRuntime.EncodeValue Bit-with-bitIndex branch points at the driver dispatch. Tests — 5 new AbCipBoolInDIntRmwTests (bit set ORs + preserves, bit clear ANDs + preserves, 8-way concurrent writes to same parent compose to 0xFF, different-parent writes get separate runtimes, repeat bit writes reuse the parent runtime init-count 1 + write-count 2), 4 new AbLegacyBitRmwTests (bit set preserves, bit clear preserves 0xFFF7, 8-way concurrent 0xFF, repeat writes reuse parent). Two pre-existing tests flipped — AbCipDriverWriteTests.Bit_in_dint_write_returns_BadNotSupported + AbLegacyReadWriteTests.Bit_within_word_write_rejected_as_BadNotSupported both now assert Good instead of BadNotSupported, renamed to _now_succeeds_via_RMW. Total tests — AbCip 166/166, AbLegacy 96/96, full solution builds 0 errors; Modbus + FOCAS + TwinCAT + other drivers untouched. Task #181 done across all four libplctag-backed + non-libplctag drivers (Modbus BitInRegister + AbCip BOOL-in-DINT + AbLegacy N-file bit + FOCAS PMC Bit — all with per-parent-word serialisation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:34:29 -04:00

153 lines
6.2 KiB
C#

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