Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPmcBitRmwTests.cs
Joseph Doherty 8c309aebf3 RMW pass 1 — Modbus BitInRegister + FOCAS PMC Bit write paths. First half of task #181 — the two drivers where read-modify-write is a clean protocol-level insertion (Modbus FC03/FC06 round-trip + FOCAS pmc_rdpmcrng / pmc_wrpmcrng round-trip). Per-driver SemaphoreSlim registry keyed on the parent word address serialises concurrent bit writes so two writers targeting different bits in the same word don't lose one another's update. Modbus — ModbusDriver gains WriteBitInRegisterAsync + _rmwLocks ConcurrentDictionary. WriteOneAsync routes BitInRegister (HoldingRegisters region only) through RMW ahead of the normal encode path. Read uses FC03 Read Holding Registers for 1 register at tag.Address, bit-op on the returned ushort via (current | 1<<bit) for set / (current & ~(1<<bit)) for clear, write back via FC06 Write Single Register. Per-address lock prevents concurrent bit writes to the same register from racing. Rejects out-of-range bits (0-15) with InvalidOperationException. EncodeRegister's BitInRegister branch repurposed as a defensive guard — if a non-RMW caller ever reaches it, throw so an unintended bypass stays loud rather than silently clobbering. FOCAS — FwlibFocasClient gains WritePmcBitAsync + _rmwLocks keyed on {addrType}:{byteAddr}. Driver-layer WriteAsync routes Bit writes with a bitIndex through the new path; other Pmc writes still hit the direct pmc_wrpmcrng path. RMW uses cnc_rdpmcrng + Byte dataType to grab the parent byte, bit-op with (current | 1<<bit) or (current & ~(1<<bit)), cnc_wrpmcrng to write back. Rejects out-of-range bits (0-7, FOCAS PMC bytes are 8-bit) with InvalidOperationException. EncodePmcValue's Bit branch now treats a no-bitIndex case as whole-byte boolean (non-zero / zero); bitIndex-present writes never hit this path because they dispatch to WritePmcBitAsync upstream. Tests — 5 new ModbusBitRmwTests + 4 new FocasPmcBitRmwTests + 1 renamed pre-existing test each covering — bit set preserves other bits, bit clear preserves other bits, concurrent bit writes to same word/byte compose correctly (8-parallel stress), bit writes on different parent words proceed without contention (4-parallel), sequential bit sets compose into 0xFF after all 8. Fake PmcRmwFake in FOCAS tests simulates the PMC byte storage + surfaces it through the IFocasClient contract so the test asserts driver-level behavior without needing Fwlib32.dll. FwlibNativeHelperTests.EncodePmcValue_Bit_throws_NotSupported_for_RMW_gap replaced with EncodePmcValue_Bit_without_bit_index_writes_byte_boolean reflecting the new behavior. ModbusDataTypeTests.BitInRegister_write_is_not_supported_in_PR24 renamed to BitInRegister_EncodeRegister_still_rejects_direct_calls; the message assertion updated to match the new defensive message. Modbus tests now 182/182, FOCAS tests now 119/119; full solution builds 0 errors; AbCip/AbLegacy/TwinCAT untouched (those get their RMW pass in a follow-up since libplctag bit access may need a parallel parent-word handle). Task #181 stays pending until that second pass lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:25:27 -04:00

124 lines
5.1 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasPmcBitRmwTests
{
/// <summary>
/// Fake client simulating PMC byte storage + exposing it as a sbyte so RMW callers can
/// observe the read-modify-write round-trip. ReadAsync for a Bit with bitIndex surfaces
/// the current bit; WriteAsync stores the full byte the driver issues.
/// </summary>
private sealed class PmcRmwFake : FakeFocasClient
{
public byte[] PmcBytes { get; } = new byte[1024];
public override Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
return Task.FromResult(((object?)(sbyte)PmcBytes[address.Number], FocasStatusMapper.Good));
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
return Task.FromResult(((object?)((PmcBytes[address.Number] & (1 << bit)) != 0), FocasStatusMapper.Good));
return base.ReadAsync(address, type, ct);
}
public override Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
// Driver writes the full byte after RMW (type==Byte with full byte value), OR a raw
// bit write (type==Bit, bitIndex non-null) — depending on how the driver routes it.
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
{
PmcBytes[address.Number] = (byte)Convert.ToSByte(value);
return Task.FromResult(FocasStatusMapper.Good);
}
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
{
var current = PmcBytes[address.Number];
PmcBytes[address.Number] = Convert.ToBoolean(value)
? (byte)(current | (1 << bit))
: (byte)(current & ~(1 << bit));
return Task.FromResult(FocasStatusMapper.Good);
}
return base.WriteAsync(address, type, value, ct);
}
}
private static (FocasDriver drv, PmcRmwFake fake) NewDriver(params FocasTagDefinition[] tags)
{
var fake = new PmcRmwFake();
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
Tags = tags,
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, fake);
}
[Fact]
public async Task Bit_set_surfaces_as_Good_status_and_flips_bit()
{
var (drv, fake) = NewDriver(
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.PmcBytes[100] = 0b0000_0001;
var results = await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
fake.PmcBytes[100].ShouldBe((byte)0b0000_1001);
}
[Fact]
public async Task Bit_clear_preserves_other_bits()
{
var (drv, fake) = NewDriver(
new FocasTagDefinition("Flag", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.PmcBytes[100] = 0xFF;
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
fake.PmcBytes[100].ShouldBe((byte)0b1111_0111);
}
[Fact]
public async Task Subsequent_bit_sets_in_same_byte_compose_correctly()
{
var tags = Enumerable.Range(0, 8)
.Select(b => new FocasTagDefinition($"Bit{b}", "focas://10.0.0.5:8193", $"R100.{b}", FocasDataType.Bit))
.ToArray();
var (drv, fake) = NewDriver(tags);
await drv.InitializeAsync("{}", CancellationToken.None);
fake.PmcBytes[100] = 0;
for (var b = 0; b < 8; b++)
await drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None);
fake.PmcBytes[100].ShouldBe((byte)0xFF);
}
[Fact]
public async Task Bit_write_to_different_bytes_does_not_contend()
{
var tags = Enumerable.Range(0, 4)
.Select(i => new FocasTagDefinition($"Bit{i}", "focas://10.0.0.5:8193", $"R{50 + i}.0", FocasDataType.Bit))
.ToArray();
var (drv, fake) = NewDriver(tags);
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 4).Select(i =>
drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None)));
for (var i = 0; i < 4; i++)
fake.PmcBytes[50 + i].ShouldBe((byte)0x01);
}
}