feat(twincat): BOOL-within-word writes via driver-level parent-word RMW

This commit is contained in:
Joseph Doherty
2026-06-17 11:55:44 -04:00
parent 5c6b7cd6f9
commit a73e20fb28
3 changed files with 127 additions and 5 deletions
@@ -266,4 +266,86 @@ public sealed class TwinCATReadWriteTests
factory.Clients[0].DisposeCount.ShouldBe(1);
}
// ---- BOOL-within-word RMW writes ----
/// <summary>A BOOL-within-word set reads the parent word, ORs the bit, writes it back as UDInt.</summary>
[Fact]
public async Task Bit_set_RMWs_parent_word_as_UDInt()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0b0001u } };
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0b1001u);
factory.Clients[0].WriteLog.ShouldContain(e =>
e.symbol == "MAIN.Flags" && e.type == TwinCATDataType.UDInt && e.bit == null);
}
/// <summary>A BOOL-within-word clear preserves the other bits in the parent word.</summary>
[Fact]
public async Task Bit_clear_preserves_other_bits()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0xFFFFu } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0xFFF7u);
}
/// <summary>RMW works on a DWORD parent (bit 20 set above the 16-bit boundary).</summary>
[Fact]
public async Task Bit_set_on_DWORD_parent_sets_high_bit()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "GVL.Status.20", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Status"] = 0u } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
factory.Clients[0].Values["GVL.Status"].ShouldBe(1u << 20);
}
/// <summary>A failed parent read short-circuits the RMW and surfaces the read status.</summary>
[Fact]
public async Task Bit_write_surfaces_parent_read_failure()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient
{
ReadStatuses = { ["MAIN.Flags"] = TwinCATStatusMapper.BadNodeIdUnknown },
};
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
factory.Clients[0].WriteLog.ShouldBeEmpty();
}
/// <summary>Concurrent bit writes to the same word compose correctly (per-parent lock).</summary>
[Fact]
public async Task Concurrent_bit_writes_to_same_word_compose_correctly()
{
var tags = Enumerable.Range(0, 8)
.Select(b => new TwinCATTagDefinition($"Bit{b}", "ads://5.23.91.23.1.1:851", $"MAIN.Flags.{b}", TwinCATDataType.Bool))
.ToArray();
var (drv, factory) = NewDriver(tags);
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0u } };
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
Convert.ToUInt32(factory.Clients[0].Values["MAIN.Flags"]).ShouldBe(0xFFu);
}
}