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 { /// Verifies that setting a bit reads the parent word, ORs the bit, and writes back. [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); } /// Verifies that clearing a bit preserves other bits in the word. [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)); } /// Verifies that concurrent bit writes to the same word compose correctly. [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); } /// Verifies that repeated bit writes reuse the parent word runtime. [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 } /// B/I/O-file bit set reads the parent word, ORs the bit, writes it back (was BadNotSupported). [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); } /// B-file bit clear preserves the other bits in the parent word. [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)); } /// Concurrent bit writes to the same B-file word compose correctly (per-parent lock). [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); } /// /// 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 clears /// the lock map so fresh semaphores are created for the new session rather than re-using /// (already-disposed) handles from the old one. /// [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(); } /// /// 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. /// [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)); } }