using System.Buffers.Binary; using System.Text; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// #740 / R07:H20 — ParseAlarms picked the 76-byte vendor layout whenever Length % 76 == 0, so a /// legacy 80-byte reply of exactly 19 records (1,520 B = 20 × 76) decoded as 20 garbage vendor records. The /// response block carries no record count, so the layout is now chosen from the two things that are known: the /// count the REQUEST asked for (a reply never holds more) and each layout's own field invariants. /// [Trait("Category", "Unit")] public sealed class FocasAlarmLayoutTests { private static byte[] Legacy(params (int no, int type, int axis, string msg)[] records) { var buf = new byte[records.Length * 80]; for (var i = 0; i < records.Length; i++) { var o = i * 80; var (no, type, axis, msg) = records[i]; BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no); BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 4, 4), type); BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 8, 4), axis); BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 12, 4), msg.Length); Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 16)); } return buf; } private static byte[] Vendor(params (int no, short type, short axis, string msg)[] records) { var buf = new byte[records.Length * 76]; for (var i = 0; i < records.Length; i++) { var o = i * 76; var (no, type, axis, msg) = records[i]; BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no); BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 4, 2), type); BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 6, 2), axis); BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 10, 2), (short)msg.Length); Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 12)); } return buf; } private static (int, int, int, string)[] LegacySet(int n) => [.. Enumerable.Range(0, n).Select(i => (400 + i, 2 + (i % 3), 1 + (i % 4), $"SERVO ALARM {400 + i}"))]; private static (int, short, short, string)[] VendorSet(int n) => [.. Enumerable.Range(0, n).Select(i => (1000 + i, (short)(4 + (i % 3)), (short)(i % 2), $"OVERTRAVEL +{i}"))]; /// The ambiguous length: 19 legacy records decode as 19 legacy records, not 20 vendor ones. [Fact] public void Nineteen_legacy_records_decode_as_legacy() { var payload = Legacy(LegacySet(19)); payload.Length.ShouldBe(1520); (payload.Length % 76).ShouldBe(0, "the length really is ambiguous"); var alarms = FocasWireClient.ParseAlarms(payload, 32); alarms.Count.ShouldBe(19); alarms[0].AlarmNumber.ShouldBe(400); alarms[0].Message.ShouldBe("SERVO ALARM 400"); alarms[18].AlarmNumber.ShouldBe(418); alarms[18].Axis.ShouldBe((short)(1 + (18 % 4))); } /// Positive control at the same length: 20 vendor records still decode as vendor. [Fact] public void Twenty_vendor_records_decode_as_vendor() { var payload = Vendor(VendorSet(20)); payload.Length.ShouldBe(1520); var alarms = FocasWireClient.ParseAlarms(payload, 32); alarms.Count.ShouldBe(20); alarms[0].AlarmNumber.ShouldBe(1000); alarms[19].Message.ShouldBe("OVERTRAVEL +19"); } /// /// Review I2 edge: vendor records with type 0 and EMPTY messages also satisfy the legacy invariants (the /// int32 read of type<<16 | axis is just the axis, and the "length" int32 is the empty message's /// zero bytes) — both readings are plausible, so the vendor default must hold and the numbers decode. /// [Fact] public void Twenty_vendor_records_with_type_zero_and_empty_messages_stay_vendor() { var payload = Vendor([.. Enumerable.Range(0, 20).Select(i => (2000 + i, (short)0, (short)(i % 3), ""))]); payload.Length.ShouldBe(1520); var alarms = FocasWireClient.ParseAlarms(payload, 32); alarms.Count.ShouldBe(20); alarms.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(2000, 20)); alarms.ShouldAllBe(a => a.Type == 0 && a.Message == ""); } /// The unambiguous lengths are unchanged. [Fact] public void Unambiguous_lengths_are_unchanged() { FocasWireClient.ParseAlarms(Vendor(VendorSet(3)), 32).Count.ShouldBe(3); FocasWireClient.ParseAlarms(Legacy(LegacySet(3)), 32).Count.ShouldBe(3); FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty(); Should.Throw(() => FocasWireClient.ParseAlarms(new byte[77], 32)); } }