using System.Buffers.Binary; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// cnc_rdmacro (0x0015) decode, pinned to real FANUC 31i-B payloads. Ported from the /// sister ignitionoee FOCAS driver's v0.15.0 fix (review finding Focas.Wire-013 / Focas-017). /// /// /// The on-wire ODBM payload is 8 bytes: [0..3] i32 mantissa, /// [4..5] i16 — a constant 10 on every observed response (a length field, NOT the /// decimal), and [6..7] i16 dec_val (0xFFFF = vacant). Reading the decimal at /// offset 4 decodes every macro on every control without ever failing — just wrongly: the /// captured #500 = 1.0 (05 f5 e1 00 00 0a 00 08) read as 100000000 / 10^10 = /// 0.01. FANUC normalises a macro to a 9-significant-digit mantissa, so the live vectors /// below (Makino Z-34184, 2026-08-05) all carry large mantissas. /// [Trait("Category", "Unit")] public sealed class FocasMacroDecodeTests { private static byte[] Odbm(int mantissa, int decVal, int lengthField = 10) { var p = new byte[8]; BinaryPrimitives.WriteInt32BigEndian(p.AsSpan(0, 4), mantissa); BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(4, 2), (ushort)lengthField); BinaryPrimitives.WriteUInt16BigEndian(p.AsSpan(6, 2), (ushort)(decVal & 0xFFFF)); return p; } [Fact] public void ParseMacro_decodes_the_live_capture_with_the_decimal_at_offset_6() { var payload = WireFixtures.Payload("ref-macro-500-0x0015.bin", 0x0015); var m = FocasWireClient.ParseMacro(500, payload); m.Number.ShouldBe((short)500); m.Value.ShouldBe(100_000_000); m.Decimal.ShouldBe((short)8); // offset 6 — NOT the constant 10 at offset 4 m.IsVacant.ShouldBeFalse(); m.Scaled!.Value.ShouldBe(1.0, 1e-12); } [Fact] public void ParseMacro_ignores_the_length_field_at_offset_4() { var a = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)); var b = FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6, lengthField: 0)); b.ShouldBe(a); b.Scaled!.Value.ShouldBe(148.5, 1e-9); } [Theory] [InlineData(270_000_000, 6, 270.0)] // #502 [InlineData(148_500_000, 6, 148.5)] // #508 [InlineData(208_850_000, 4, 20885.0)] // #610 [InlineData(202_608_050, 1, 20_260_805.0)] // #3011 date [InlineData(-100_000_000, 12, -1.0e-4)] // #802 [InlineData(969_479_000, 9, 0.969479)] // #801 [InlineData(1234, 0, 1234.0)] public void ParseMacro_scales_the_live_vectors(int mantissa, int dec, double expected) { FocasWireClient.ParseMacro(1, Odbm(mantissa, dec)).Scaled!.Value.ShouldBe(expected, 1e-9); } [Fact] public void A_vacant_macro_has_no_value_not_zero() { var m = FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF)); m.Decimal.ShouldBe((short)-1); m.IsVacant.ShouldBeTrue(); m.Scaled.ShouldBeNull(); } [Fact] public void A_genuine_zero_is_not_vacant() { var m = FocasWireClient.ParseMacro(3901, Odbm(0, 0)); m.IsVacant.ShouldBeFalse(); m.Scaled.ShouldBe(0.0); } [Theory] [InlineData(0)] [InlineData(4)] [InlineData(6)] [InlineData(7)] public void ParseMacro_refuses_a_payload_too_short_to_carry_dec_val(int length) { Should.Throw(() => FocasWireClient.ParseMacro(1, new byte[length])); } // ---- published value / quality ---- [Fact] public void Float64_publishes_the_scaled_value_good() { var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Float64, null); status.ShouldBe(FocasStatusMapper.Good); value.ShouldBe(148.5); } [Fact] public void Int32_publishes_the_exact_whole_value_not_the_mantissa() { var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(502, Odbm(270_000_000, 6)), FocasDataType.Int32, null); status.ShouldBe(FocasStatusMapper.Good); value.ShouldBe(270); } [Fact] public void A_fractional_macro_on_an_integer_tag_is_bad_not_truncated() { var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(508, Odbm(148_500_000, 6)), FocasDataType.Int32, null); status.ShouldBe(FocasStatusMapper.BadTypeMismatch); value.ShouldBeNull(); } [Theory] [InlineData(FocasDataType.Float64)] [InlineData(FocasDataType.Float32)] [InlineData(FocasDataType.Int32)] public void A_vacant_macro_is_bad_for_every_type(FocasDataType type) { var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(611, Odbm(0, 0xFFFF)), type, null); status.ShouldBe(FocasStatusMapper.BadNoData); value.ShouldBeNull(); } [Fact] public void A_whole_macro_out_of_range_for_its_integer_type_is_bad() { // 70000 is whole, but does not fit Int16. var (value, status) = WireFocasClient.MapMacro(FocasWireClient.ParseMacro(1, Odbm(700_000_000, 4)), FocasDataType.Int16, null); status.ShouldBe(FocasStatusMapper.BadOutOfRange); value.ShouldBeNull(); } }