fix(abcip): native runtime write guards, same-width integer exemption, deferred path encoding (Task 12 review r1)

This commit is contained in:
Joseph Doherty
2026-10-07 16:56:30 -04:00
parent 25cc836865
commit e2e441d45e
2 changed files with 159 additions and 10 deletions
@@ -248,6 +248,103 @@ public sealed class NativeAbCipTagRuntimeTests
ch.SentCount.ShouldBe(sentBefore);
}
[Fact]
public async Task A_Dt_write_under_DATE_type_0xCD_sends_four_bytes()
{
// 0xCD is outside LogixTypeCode's width table: no width check, the write goes out as today.
var ch = Replying("CD00", "00000000");
using var rt = await ReadAsync(ch, Params("D", AbCipDataType.Dt));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
ch.Respond = req => Task.FromResult(FakeCipChannel.OkReply(req));
rt.EncodeValue(AbCipDataType.Dt, null, 123456);
await rt.WriteAsync(CancellationToken.None);
ch.Sent[^1].ShouldBe(Convert.FromHexString("4D02" + "91014400" + "CD00" + "0100" + "40E20100"));
}
[Fact]
public async Task A_BOOL_write_over_a_packed_BOOL_array_DWORD_is_NotSupported_and_sends_nothing()
{
var ch = Replying("D300", "01000000");
using var rt = await ReadAsync(ch, Params("Bits[0]", AbCipDataType.Bool));
var sentBefore = ch.SentCount;
rt.EncodeValue(AbCipDataType.Bool, null, false);
await Should.ThrowAsync<NotSupportedException>(() => rt.WriteAsync(CancellationToken.None));
ch.SentCount.ShouldBe(sentBefore);
}
[Theory]
[InlineData(AbCipDataType.DInt)]
[InlineData(AbCipDataType.Int)]
[InlineData(AbCipDataType.Bool)]
public async Task Any_write_to_a_bit_tag_is_NotSupported_and_sends_nothing(AbCipDataType authored)
{
// "Flags.3" authored as DInt/Int would otherwise write the value over the WHOLE host DINT.
var ch = Replying("C400", "08000000");
using var rt = await ReadAsync(ch, Params("Flags.3", authored));
var sentBefore = ch.SentCount;
rt.EncodeValue(authored, null, 1);
await Should.ThrowAsync<NotSupportedException>(() => rt.WriteAsync(CancellationToken.None));
ch.SentCount.ShouldBe(sentBefore);
}
[Fact]
public async Task A_write_after_a_same_width_type_mismatch_is_TypeMismatch_and_sends_nothing()
{
// REAL returned for a DINT-authored tag: same width, so only the read rule can catch it.
var ch = Replying("CA00", "0000803F");
using var rt = await ReadAsync(ch, Params("Speed", AbCipDataType.DInt));
var sentBefore = ch.SentCount;
rt.EncodeValue(AbCipDataType.DInt, null, 42);
(await Should.ThrowAsync<AbTransportException>(() => rt.WriteAsync(CancellationToken.None)))
.Status.ShouldBe(AbStatus.TypeMismatch);
ch.SentCount.ShouldBe(sentBefore);
rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch);
}
[Theory]
[InlineData(AbCipDataType.DInt, "C800", "2A000000", 42)] // DINT ← UDINT
[InlineData(AbCipDataType.UDInt, "C400", "2A000000", 42u)] // UDINT ← DINT
[InlineData(AbCipDataType.Int, "C700", "2A00", 42)] // INT ← UINT
[InlineData(AbCipDataType.UInt, "C300", "2A00", 42)] // UINT ← INT
[InlineData(AbCipDataType.SInt, "C600", "2A", 42)] // SINT ← USINT
[InlineData(AbCipDataType.USInt, "C200", "2A", 42)] // USINT ← SINT
[InlineData(AbCipDataType.LInt, "C900", "2A00000000000000", 42L)] // LINT ← ULINT
[InlineData(AbCipDataType.ULInt, "C500", "2A00000000000000", 42UL)] // ULINT ← LINT
[InlineData(AbCipDataType.DInt, "D300", "2A000000", 42)] // DINT ← Micro800 DWORD
[InlineData(AbCipDataType.UDInt, "D300", "2A000000", 42u)] // UDINT ← Micro800 DWORD
[InlineData(AbCipDataType.Int, "D200", "2A00", 42)] // INT ← Micro800 WORD
[InlineData(AbCipDataType.USInt, "D100", "2A", 42)] // USINT ← Micro800 BYTE
[InlineData(AbCipDataType.LInt, "D400", "2A00000000000000", 42L)] // LINT ← Micro800 LWORD
public async Task Same_width_integer_pairs_are_not_a_type_mismatch(
AbCipDataType authored, string typeHex, string dataHex, object expected)
{
using var rt = await ReadAsync(Replying(typeHex, dataHex), Params("T", authored));
rt.GetStatus().ShouldBe((int)AbStatus.Ok);
rt.DecodeValue(authored, null).ShouldBe(expected);
}
[Theory]
[InlineData(AbCipDataType.Int, "C400", "2A000000")] // different width
[InlineData(AbCipDataType.Real, "D300", "2A000000")] // integer ↔ real, same width
[InlineData(AbCipDataType.UDInt, "CA00", "0000803F")] // real ↔ integer, same width
public async Task Different_widths_and_integer_real_pairs_still_mismatch(
AbCipDataType authored, string typeHex, string dataHex)
{
using var rt = await ReadAsync(Replying(typeHex, dataHex), Params("T", authored));
rt.GetStatus().ShouldBe((int)AbStatus.TypeMismatch);
}
[Fact]
public async Task A_malformed_tag_name_never_throws_from_Create_and_surfaces_on_Initialize_without_IO()
{
var ch = new FakeCipChannel();
var p = Params("Arr[abc]", AbCipDataType.DInt);
using var created = new NativeAbCipTagFactory().Create(p); // must not throw
using var rt = NativeAbCipTagRuntime.ForTest(ch, p);
await Should.ThrowAsync<Exception>(() => rt.InitializeAsync(CancellationToken.None));
ch.SentCount.ShouldBe(0);
}
[Fact]
public async Task A_refused_write_stores_the_status_and_rethrows()
{