Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs
T
Joseph Doherty 40c7493829 fix(twincat): BOOL-within-word writes RMW the parent at its own width (H18)
The bit write read and wrote the parent word as a 32-bit UDInt with no parent
check: a bit beyond a 16-bit parent was written into neighbouring bytes, and a
signed parent with its sign bit set read back >= 2^31, so the checked
conversion turned any bit write into BadOutOfRange.

The write now goes through ITwinCATClient.WriteBitAsync: the ADS client
resolves the parent symbol, checks the bit against it (TwinCATBitAccess.
CheckParent, as the notification path already did), reads exactly
parent.ByteSize raw bytes, flips one bit (TwinCATBitAccess.ApplyBitWrite) and
writes the bytes back by index group/offset or variable handle. A bit beyond
the parent is BadOutOfRange, a non-integer parent BadTypeMismatch, a short
parent read BadCommunicationError (never written back), all without a health
write. The per-parent RMW lock stays in the driver.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:10:12 -04:00

418 lines
19 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATReadWriteTests
{
private static (TwinCATDriver drv, FakeTwinCATClientFactory factory) NewDriver(params TwinCATTagDefinition[] tags)
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
RawTags = TwinCATRawTags.Entries(tags),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
return (drv, factory);
}
// ---- Read ----
/// <summary>Verifies that an unknown reference maps to BadNodeIdUnknown status.</summary>
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
var (drv, _) = NewDriver();
await drv.InitializeAsync("{}", CancellationToken.None);
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that a successful DInt read returns Good status and the correct value.</summary>
[Fact]
public async Task Successful_DInt_read_returns_Good_value()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Speed"] = 4200 } };
var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
snapshots.Single().Value.ShouldBe(4200);
factory.Clients[0].ConnectCount.ShouldBe(1);
factory.Clients[0].IsConnected.ShouldBeTrue();
}
/// <summary>Verifies that repeated read operations reuse the same connection.</summary>
[Fact]
public async Task Repeat_read_reuses_connection()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "GVL.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.X"] = 1 } };
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ReadAsync(["X"], CancellationToken.None);
// One client, one connect — subsequent calls reuse the connected client.
factory.Clients.Count.ShouldBe(1);
factory.Clients[0].ConnectCount.ShouldBe(1);
}
/// <summary>Verifies that ADS read errors are mapped via the status mapper.</summary>
[Fact]
public async Task Read_with_ADS_error_maps_via_status_mapper()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Ghost", "ads://5.23.91.23.1.1:851", "MAIN.Missing", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () =>
{
var c = new FakeTwinCATClient();
c.ReadStatuses["MAIN.Missing"] = TwinCATStatusMapper.BadNodeIdUnknown;
return c;
};
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that read exceptions surface as BadCommunicationError status.</summary>
[Fact]
public async Task Read_exception_surfaces_BadCommunicationError()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { ThrowOnRead = true };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>Verifies that connect failures surface BadCommunicationError and dispose the client.</summary>
[Fact]
public async Task Connect_failure_surfaces_BadCommunicationError_and_disposes_client()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { ThrowOnConnect = true };
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
factory.Clients[0].DisposeCount.ShouldBe(1);
}
/// <summary>Verifies that batched read operations preserve the order of results.</summary>
[Fact]
public async Task Batched_reads_preserve_order()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.Real),
new TwinCATTagDefinition("C", "ads://5.23.91.23.1.1:851", "MAIN.C", TwinCATDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient
{
Values =
{
["MAIN.A"] = 1,
["MAIN.B"] = 3.14f,
["MAIN.C"] = "hello",
},
};
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
snapshots[0].Value.ShouldBe(1);
snapshots[1].Value.ShouldBe(3.14f);
snapshots[2].Value.ShouldBe("hello");
}
// ---- Write ----
/// <summary>Verifies that non-writable tags are rejected with BadNotWritable status.</summary>
[Fact]
public async Task Non_writable_tag_rejected_with_BadNotWritable()
{
var (drv, _) = NewDriver(
new TwinCATTagDefinition("RO", "ads://5.23.91.23.1.1:851", "MAIN.RO", TwinCATDataType.DInt, Writable: false));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("RO", 1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
}
/// <summary>Verifies that successful writes log the symbol, type, and value correctly.</summary>
[Fact]
public async Task Successful_write_logs_symbol_type_value()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Speed", "ads://5.23.91.23.1.1:851", "MAIN.Speed", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Speed", 4200)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
var write = factory.Clients[0].WriteLog.Single();
write.symbol.ShouldBe("MAIN.Speed");
write.type.ShouldBe(TwinCATDataType.DInt);
write.value.ShouldBe(4200);
}
/// <summary>Verifies that ADS write errors are mapped and surfaced correctly.</summary>
[Fact]
public async Task Write_with_ADS_error_surfaces_mapped_status()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () =>
{
var c = new FakeTwinCATClient();
c.WriteStatuses["MAIN.X"] = TwinCATStatusMapper.BadNotWritable;
return c;
};
var results = await drv.WriteAsync(
[new WriteRequest("X", 1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
}
/// <summary>A refused temporal write (#306) surfaces a status through WriteAsync, never an exception.</summary>
[Theory]
[InlineData(TwinCATDataType.TimeOfDay, 86_400_000d, TwinCATStatusMapper.BadOutOfRange)]
[InlineData(TwinCATDataType.Time, -1d, TwinCATStatusMapper.BadOutOfRange)]
[InlineData(TwinCATDataType.DateTime, "TimeSpan", TwinCATStatusMapper.BadTypeMismatch)]
public async Task Refused_temporal_write_surfaces_status_not_exception(
TwinCATDataType type, object value, uint expected)
{
var (drv, factory) = NewDriver(new TwinCATTagDefinition("T", "ads://5.23.91.23.1.1:851", "MAIN.T", type));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new ConvertingFake();
if (value is "TimeSpan") value = TimeSpan.FromSeconds(1);
var results = await drv.WriteAsync([new WriteRequest("T", value)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(expected);
}
// Runs the production write conversion the fake otherwise skips.
private sealed class ConvertingFake : FakeTwinCATClient
{
public override Task<uint> WriteValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{
AdsTwinCATClient.ConvertForWrite(type, value);
return base.WriteValueAsync(symbolPath, type, bitIndex, value, ct);
}
}
/// <summary>Verifies that write exceptions surface as BadCommunicationError status.</summary>
[Fact]
public async Task Write_exception_surfaces_BadCommunicationError()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { ThrowOnWrite = true };
var results = await drv.WriteAsync(
[new WriteRequest("X", 1)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
}
/// <summary>Verifies that batched write operations preserve order across mixed outcomes.</summary>
[Fact]
public async Task Batch_write_preserves_order_across_outcomes()
{
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions("ads://5.23.91.23.1.1:851")],
RawTags = TwinCATRawTags.Entries(
[
new TwinCATTagDefinition("A", "ads://5.23.91.23.1.1:851", "MAIN.A", TwinCATDataType.DInt),
new TwinCATTagDefinition("B", "ads://5.23.91.23.1.1:851", "MAIN.B", TwinCATDataType.DInt, Writable: false),
]),
Probe = new TwinCATProbeOptions { Enabled = false },
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[
new WriteRequest("A", 1),
new WriteRequest("B", 2),
new WriteRequest("Unknown", 3),
], CancellationToken.None);
results.Count.ShouldBe(3);
results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadNotWritable);
results[2].StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that cancellation tokens propagate correctly during read operations.</summary>
[Fact]
public async Task Cancellation_propagates()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient
{
ThrowOnRead = true,
Exception = new OperationCanceledException(),
};
await Should.ThrowAsync<OperationCanceledException>(
() => drv.ReadAsync(["X"], CancellationToken.None));
}
/// <summary>Verifies that shutdown disposes the client correctly.</summary>
[Fact]
public async Task ShutdownAsync_disposes_client()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("X", "ads://5.23.91.23.1.1:851", "MAIN.X", TwinCATDataType.DInt));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.X"] = 1 } };
await drv.ReadAsync(["X"], CancellationToken.None);
await drv.ShutdownAsync(CancellationToken.None);
factory.Clients[0].DisposeCount.ShouldBe(1);
}
// ---- BOOL-within-word RMW writes (#739 H18: at the parent's own width, on raw bytes) ----
/// <summary>A BOOL-within-word set read-modify-writes the parent word's raw bytes through WriteBitAsync.</summary>
[Fact]
public async Task Bit_set_RMWs_the_parent_word_bytes()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0b0001, 0, 0, 0] } };
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].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0b1001, 0, 0, 0 });
factory.Clients[0].BitWriteLog.ShouldBe([("MAIN.Flags", 3, true)]);
factory.Clients[0].WriteLog.ShouldBeEmpty(); // no 32-bit UDInt round-trip any more
}
/// <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 { ParentWords = { ["MAIN.Flags"] = [0xFF, 0xFF] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xF7, 0xFF });
}
/// <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 { ParentWords = { ["GVL.Status"] = [0, 0, 0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
BitConverter.ToUInt32(factory.Clients[0].ParentWords["GVL.Status"]).ShouldBe(1u << 20);
}
/// <summary>
/// A bit beyond a 16-bit parent is refused BadOutOfRange and nothing is written — before, the hardcoded
/// 32-bit RMW wrote into the neighbouring bytes. No health write: a refusal is not a comms fault.
/// </summary>
[Fact]
public async Task Bit_beyond_a_16_bit_parent_is_refused_without_writing()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "MAIN.Word.20", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Word"] = [0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
factory.Clients[0].ParentWords["MAIN.Word"].ShouldBe(new byte[] { 0, 0 });
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <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
{
ParentWords = { ["MAIN.Flags"] = [0xAA, 0] },
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].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xAA, 0 });
}
/// <summary>A Good parent read that yields no bytes must NOT zero the word — it surfaces a Bad status
/// and writes nothing (treating it as 0 would clear every set bit).</summary>
[Fact]
public async Task Bit_write_with_no_parent_bytes_does_not_zero_the_word()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
// Do NOT seed ParentWords["MAIN.Flags"] — the fake answers the parent read Good with no bytes.
factory.Customise = () => new FakeTwinCATClient();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
factory.Clients[0].ParentWords.ShouldNotContainKey("MAIN.Flags");
}
/// <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 { YieldInBitWrite = true, ParentWords = { ["MAIN.Flags"] = [0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xFF, 0 });
}
}