Merge #867 Heidenhain integer leaves keep their declared type; #868 ConfigPublishCoordinator survives ConfigDb outage at start

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
Joseph Doherty
2026-11-11 12:29:00 -05:00
11 changed files with 720 additions and 41 deletions
@@ -0,0 +1,133 @@
using System.Buffers.Binary;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
namespace ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests;
/// <summary>
/// lmxopcua#867: the published value's RUNTIME type must be the CLR type of the leaf's declared data type.
/// Shouldly equality cannot catch this (7.0 equals 7L), so every assertion here compares <c>GetType()</c>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HeidenhainLeafRuntimeTypeTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private static Type ClrType(DriverDataType type, bool isArray)
{
var scalar = type switch
{
DriverDataType.Boolean => typeof(bool),
DriverDataType.Int16 => typeof(short),
DriverDataType.Int32 => typeof(int),
DriverDataType.Int64 => typeof(long),
DriverDataType.Float64 => typeof(double),
DriverDataType.String => typeof(string),
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null),
};
return isArray ? scalar.MakeArrayType() : scalar;
}
/// <summary>Every fixed value leaf (capability strings excluded), with its declared type.</summary>
public static TheoryData<string> CatalogValueLeaves()
{
var data = new TheoryData<string>();
foreach (var e in HeidenhainCatalog.Entries.Where(e => !e.IsCapability)) data.Add(e.Address);
return data;
}
private static SimulatedControl FullyPopulatedControl()
{
var c = new SimulatedControl();
c.Runtime[22] = () => SimReply.Ok("S_RI", [0, 1, .. Encoding.ASCII.GetBytes("1.5\0X\0")]);
c.Runtime[23] = () => SimReply.Ok("S_RI", [0, 4]);
c.Runtime[26] = () => SimReply.Ok("S_RI", [0, 7]);
c.Runtime[24] = () => SimReply.Ok("S_RI", [0, 0, 0, 5, .. "A\0B\0"u8.ToArray()]);
c.Runtime[25] = () => SimReply.Ok("S_RI", new byte[12]);
var alarm = new byte[10];
BinaryPrimitives.WriteInt16BigEndian(alarm, 3);
BinaryPrimitives.WriteInt16BigEndian(alarm.AsSpan(2), 4);
BinaryPrimitives.WriteInt32BigEndian(alarm.AsSpan(4), 12);
alarm[8] = 65;
c.Runtime[27] = () => SimReply.Ok("S_RI", alarm);
c.Runtime[28] = () => SimReply.Refuse(57);
// 24-byte tool reply: number 49, index 1, axis 2, length 100.5, radius 3.25 (float64le).
var tool = new byte[24];
BinaryPrimitives.WriteUInt32BigEndian(tool, 49);
BinaryPrimitives.WriteUInt16BigEndian(tool.AsSpan(4), 1);
BinaryPrimitives.WriteUInt16BigEndian(tool.AsSpan(6), 2);
BinaryPrimitives.WriteDoubleLittleEndian(tool.AsSpan(8), 100.5);
BinaryPrimitives.WriteDoubleLittleEndian(tool.AsSpan(16), 3.25);
c.Runtime[51] = () => SimReply.Ok("S_RI", tool);
for (var s = 30; s <= 34; s++) c.Runtime[s] = () => SimReply.Ok("S_RI", [0, 0, 1, 0]);
return c;
}
[Theory]
[MemberData(nameof(CatalogValueLeaves))]
public async Task Catalog_leaf_publishes_the_clr_type_of_its_declared_data_type(string address)
{
HeidenhainCatalog.TryGet(address, out var entry).ShouldBeTrue();
var control = FullyPopulatedControl();
await using var sim = Lsv2Simulator.WithControl(control);
var client = new Lsv2.Lsv2Client("127.0.0.1", sim.Port, TimeSpan.FromSeconds(1));
var tag = TestTags.Def(address);
var settings = new HeidenhainSessionSettings(true, true, false, 64, true, false, false);
await using var session = new HeidenhainSession(client, settings, (_, _) => ValueTask.FromResult(""));
await client.OpenAsync(Ct);
await session.InitializeAsync(Ct);
var sample = (await session.ReadBatchAsync([tag], Ct))[0];
sample.Status.ShouldBe(HeidenhainStatus.Good, address);
sample.Value.ShouldNotBeNull(address);
sample.Value.GetType().ShouldBe(ClrType(entry!.DataType, entry.IsArray), address);
control.Violations.ShouldBeEmpty();
}
[Fact]
public void Every_integer_catalog_leaf_is_covered()
{
// Pins the theory's reach: the integer leaves #867 broke are in the data set.
var leaves = CatalogValueLeaves().Select(r => r.Data).ToList();
leaves.ShouldContain("Tool/Number");
leaves.ShouldContain("Tool/Index");
leaves.ShouldContain("Tool/AxisCode");
leaves.ShouldContain("Alarms/Count");
leaves.ShouldContain("Program/Line");
leaves.ShouldContain("Status/ExecutionState");
leaves.Count(l => l.StartsWith("Timers/", StringComparison.Ordinal)).ShouldBe(HeidenhainCatalog.TimerNames.Count);
}
[Theory]
[InlineData(DriverDataType.Int16, 42)]
[InlineData(DriverDataType.Int32, 42)]
[InlineData(DriverDataType.Int64, 42)]
[InlineData(DriverDataType.Int16, 42L)]
[InlineData(DriverDataType.Int32, 42L)]
[InlineData(DriverDataType.Int64, 42L)]
[InlineData(DriverDataType.Int16, 42.0)]
[InlineData(DriverDataType.Int32, 42.0)]
[InlineData(DriverDataType.Int64, 42.0)]
[InlineData(DriverDataType.Boolean, true)]
[InlineData(DriverDataType.Boolean, false)]
public void Binding_coercion_publishes_the_clr_type_of_the_declared_data_type(DriverDataType type, object native)
{
HeidenhainBindingCodec.Coerce(native, type).GetType().ShouldBe(ClrType(type, false));
}
[Theory]
[InlineData(HeidenhainPlcArea.Byte, new byte[] { 7 }, DriverDataType.Int16)]
[InlineData(HeidenhainPlcArea.Byte, new byte[] { 7 }, DriverDataType.Int32)]
[InlineData(HeidenhainPlcArea.Word, new byte[] { 7, 0 }, DriverDataType.Int64)]
[InlineData(HeidenhainPlcArea.Dword, new byte[] { 7, 0, 0, 0 }, DriverDataType.Int32)]
[InlineData(HeidenhainPlcArea.InputWord, new byte[] { 7, 0 }, DriverDataType.Int32)]
[InlineData(HeidenhainPlcArea.Marker, new byte[] { 1 }, DriverDataType.Boolean)]
public void Plc_binding_decode_publishes_the_clr_type_of_the_declared_data_type(HeidenhainPlcArea area, byte[] bytes, DriverDataType type)
{
HeidenhainBindingCodec.DecodePlc(area, bytes, type).GetType().ShouldBe(ClrType(type, false));
}
}