Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs
T
Joseph Doherty 098adf43d0 fix(ablegacy): dispose per-parent RMW locks on teardown (review symmetry)
DisposeRuntimes() now disposes and clears _rmwLocks, _creationLocks, and
_runtimeLocks so ReinitializeAsync/ShutdownAsync cycles don't orphan their
SemaphoreSlim instances. Mirrors the TwinCAT _bitRmwLocks fix already shipped.
2026-06-17 12:10:42 -04:00

258 lines
11 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
[Trait("Category", "Unit")]
public sealed class AbLegacyBitRmwTests
{
/// <summary>Verifies that setting a bit reads the parent word, ORs the bit, and writes back.</summary>
[Fact]
public async Task Bit_set_reads_parent_word_ORs_bit_writes_back()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0b0001 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("Flag3", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { 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(AbLegacyStatusMapper.Good);
factory.Tags.ShouldContainKey("N7:0"); // parent word runtime created
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0b1001);
}
/// <summary>Verifies that clearing a bit preserves other bits in the word.</summary>
[Fact]
public async Task Bit_clear_preserves_other_bits_in_N_file_word()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0xFFFF) },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "N7:0/3", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(unchecked((short)0xFFF7));
}
/// <summary>Verifies that concurrent bit writes to the same word compose correctly.</summary>
[Fact]
public async Task Concurrent_bit_writes_to_same_word_compose_correctly()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
};
var tags = Enumerable.Range(0, 8)
.Select(b => new AbLegacyTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"N7:0/{b}", AbLegacyDataType.Bit))
.ToArray();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
Probe = new AbLegacyProbeOptions { 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["N7:0"].Value).ShouldBe(0xFF);
}
/// <summary>Verifies that repeated bit writes reuse the parent word runtime.</summary>
[Fact]
public async Task Repeat_bit_writes_reuse_parent_runtime()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags =
[
new AbLegacyTagDefinition("Bit0", "ab://10.0.0.5/1,0", "N7:0/0", AbLegacyDataType.Bit),
new AbLegacyTagDefinition("Bit5", "ab://10.0.0.5/1,0", "N7:0/5", AbLegacyDataType.Bit),
],
Probe = new AbLegacyProbeOptions { 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);
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
factory.Tags["N7:0"].WriteCount.ShouldBe(2);
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0x21); // bits 0 + 5
}
/// <summary>B/I/O-file bit set reads the parent word, ORs the bit, writes it back (was BadNotSupported).</summary>
[Theory]
[InlineData("B3:0/3", "B3:0")]
[InlineData("I:0/3", "I:0")]
[InlineData("O:1/3", "O:1")]
public async Task Bit_set_on_B_I_O_file_RMWs_parent_word(string bitAddr, string parentName)
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0b0001 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", bitAddr, AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("F", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags.ShouldContainKey(parentName);
Convert.ToInt32(factory.Tags[parentName].Value).ShouldBe(0b1001);
}
/// <summary>B-file bit clear preserves the other bits in the parent word.</summary>
[Fact]
public async Task Bit_clear_preserves_other_bits_in_B_file_word()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = unchecked((short)0xFFFF) },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "B3:0/3", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("F", false)], CancellationToken.None);
Convert.ToInt32(factory.Tags["B3:0"].Value).ShouldBe(unchecked((short)0xFFF7));
}
/// <summary>Concurrent bit writes to the same B-file word compose correctly (per-parent lock).</summary>
[Fact]
public async Task Concurrent_bit_writes_to_same_B_file_word_compose_correctly()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
};
var tags = Enumerable.Range(0, 8)
.Select(b => new AbLegacyTagDefinition($"Bit{b}", "ab://10.0.0.5/1,0", $"B3:0/{b}", AbLegacyDataType.Bit))
.ToArray();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = tags,
Probe = new AbLegacyProbeOptions { 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["B3:0"].Value).ShouldBe(0xFF);
}
/// <summary>
/// Verifies that disposing (teardown) and re-initializing the driver does not orphan the
/// per-parent RMW-lock semaphores from the previous session. A bit write after the
/// reinit cycle must succeed, proving that <see cref="DeviceState.DisposeRuntimes"/> clears
/// the lock map so fresh semaphores are created for the new session rather than re-using
/// (already-disposed) handles from the old one.
/// </summary>
[Fact]
public async Task RMW_locks_are_disposed_and_fresh_after_driver_reinitialise()
{
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p) { Value = (short)0 },
};
var opts = new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("Bit2", "ab://10.0.0.5/1,0", "N7:0/2", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
};
// First session: init + bit write — seeds an RMW lock entry for "N7:0".
var drv = new AbLegacyDriver(opts, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var firstResult = await drv.WriteAsync([new WriteRequest("Bit2", true)], CancellationToken.None);
firstResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
// Teardown — DisposeRuntimes must dispose + clear the RMW lock map.
await drv.DisposeAsync();
// Second session: re-init on a fresh driver instance (mirrors ReinitializeAsync behavior).
factory.Tags.Clear();
var drv2 = new AbLegacyDriver(opts, "drv-1", factory);
await drv2.InitializeAsync("{}", CancellationToken.None);
var secondResult = await drv2.WriteAsync([new WriteRequest("Bit2", false)], CancellationToken.None);
// Must succeed — a disposed/orphaned semaphore would throw ObjectDisposedException.
secondResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0);
await drv2.DisposeAsync();
}
/// <summary>
/// A non-zero libplctag status returned by the parent-word write is surfaced as a non-Good
/// OPC UA StatusCode — the PCCC device rejection propagates to the caller.
/// </summary>
[Fact]
public async Task Bit_write_surfaces_device_rejection_status()
{
// Arrange: the parent tag reads OK (Status = 0) but write returns a timeout error.
const int errorTimeout = (int)libplctag.Status.ErrorTimeout;
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new FakeAbLegacyTag(p)
{
Value = (short)0b0001,
WriteStatusOverride = errorTimeout,
},
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0")],
Tags = [new AbLegacyTagDefinition("F", "ab://10.0.0.5/1,0", "I:0/3", AbLegacyDataType.Bit)],
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
// Act
var results = await drv.WriteAsync([new WriteRequest("F", true)], CancellationToken.None);
// Assert: the write rejection must NOT be silently swallowed as Good.
var statusCode = results.Single().StatusCode;
statusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
statusCode.ShouldBe(AbLegacyStatusMapper.MapLibplctagStatus(errorTimeout));
}
}