fix(ablegacy): encode array writes at the data file's element width

Review r0 I3. The array encoder used the AUTHORED dataType's width while both
runtimes size their buffer from the FILE: an Int array on F8 wrote four shorts
into a 16-byte float buffer and sent it all (garbage neighbours, answered
Good); Long/Float on N overran it.

- The write path resolves the element width from the data file the address
  names (N/B/A/I/O/S 16-bit word, L 32-bit, F float) and encodes at THAT width.
- An authored type that does not match its file is refused BadTypeMismatch
  before any runtime exists (warned once, no health write).
- The same rule (AbLegacyTagDefinitionFactory.ArrayFileMismatch) backs an
  Inspect deploy warning and the AdminUI model's Validate error; FromTagConfig
  still accepts the tag so persisted configs keep loading.
- M6: the skipped F1 parity test gains the shapes the write path refuses
  (/bit, .sub, indirect, type/file mismatch, PLC-5 native).

Tests: one case per file type, a mismatch per pairing with nothing created,
and a positive control.

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 15:20:10 -04:00
parent 17ae2eea0b
commit d758dd1e16
6 changed files with 175 additions and 5 deletions
@@ -43,7 +43,24 @@ public sealed class AbLegacyArrayWriteSupportParityTests
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString());
}
/// <summary>What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float).</summary>
/// <summary>
/// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a
/// <c>/bit</c> or <c>.sub</c> address, an indirect address, a type that does not match its data file (I3), and
/// a native PLC-5 device. The F1 row must answer false for every one — keyed on <c>dataType</c> alone it would
/// advertise them writable. Skipped with the parity theory until F1 lands.
/// </summary>
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
[InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"F8:0","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"N7:0","dataType":"Long","isArray":true,"arrayLength":2}""", "{}")]
[InlineData("""{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":2}""",
"""{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.5/1,0","plcFamily":"Plc5"}]}""")]
public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) =>
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
/// <summary>What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file).</summary>
[Theory]
[InlineData(AbLegacyDataType.Int, true)]
[InlineData(AbLegacyDataType.AnalogInt, true)]
@@ -72,6 +72,68 @@ public sealed class AbLegacyArrayWriteTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Review 07 r0 I3: every data file type encodes at ITS element width (one case per file).</summary>
[Theory]
[InlineData("N7:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
[InlineData("N7:0", AbLegacyDataType.AnalogInt, AbLegacyDataType.Int)]
[InlineData("B3:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
[InlineData("A9:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
[InlineData("L9:0", AbLegacyDataType.Long, AbLegacyDataType.Long)]
[InlineData("F8:0", AbLegacyDataType.Float, AbLegacyDataType.Float)]
public async Task Each_file_type_encodes_at_the_file_element_width(string address, AbLegacyDataType authored, AbLegacyDataType fileWidth)
{
var (drv, f) = await NewAsync(authored, address, 2);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var tag = (RecordingTag)f.Tags[address];
tag.EncodedType.ShouldBe(fileWidth);
tag.Encoded!.GetType().ShouldBe(fileWidth switch
{
AbLegacyDataType.Long => typeof(int[]),
AbLegacyDataType.Float => typeof(float[]),
_ => typeof(short[]),
});
tag.WriteCount.ShouldBe(1);
}
/// <summary>
/// Review 07 r0 I3: an array whose authored type does not match its data file (Int on F8, Long on N7, Float on
/// L9) used to be encoded at the AUTHORED width into the file-typed buffer and sent — garbage in the
/// neighbouring elements, answered Good. It is now refused BadTypeMismatch before any runtime exists.
/// </summary>
[Theory]
[InlineData("F8:0", AbLegacyDataType.Int)]
[InlineData("N7:0", AbLegacyDataType.Long)]
[InlineData("N7:0", AbLegacyDataType.Float)]
[InlineData("L9:0", AbLegacyDataType.Float)]
[InlineData("L9:0", AbLegacyDataType.Int)]
public async Task A_type_that_does_not_match_its_file_is_refused_and_never_written(string address, AbLegacyDataType authored)
{
var (drv, f) = await NewAsync(authored, address, 4);
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None);
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
f.Tags.ShouldBeEmpty(); // nothing created, nothing on the wire
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
AbLegacyTagDefinitionFactory.Inspect(
$$"""{"address":"{{address}}","dataType":"{{authored}}","isArray":true,"arrayLength":4}""")
.ShouldContain(w => w.Contains("element width")); // and the deploy gate names it
}
[Fact]
public async Task Positive_control_a_Float_array_on_an_F_file_is_written()
{
var (drv, f) = await NewAsync(AbLegacyDataType.Float, "F8:0", 4);
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None))[0]
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
((RecordingTag)f.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1f, 2f, 3f, 4f });
AbLegacyTagDefinitionFactory.Inspect("""{"address":"F8:0","dataType":"Float","isArray":true,"arrayLength":4}""")
.ShouldBeEmpty();
}
[Fact]
public async Task Float_and_Long_arrays_encode_at_their_element_width()
{