Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests/ContractsWireParityTests.cs
T
Joseph Doherty cd072baad8 review(Driver.Historian.Wonderware.Client): async frame-header write + wire-parity test
Re-review at 7286d320. -011: FrameWriter folded the sync WriteByte (could block on SslStream
past the call timeout) into one async 5-byte header write. -012: DefaultTcpConnectFactory
readonly. -013: wire-parity test for PerEventStatus [Key(4)]. No wire change.
2026-06-19 11:58:15 -04:00

267 lines
11 KiB
C#

// xUnit1051: MessagePackSerializer.Serialize/Deserialize have optional CancellationToken
// overloads; these are synchronous parity tests — suppressing the false-positive advisory.
#pragma warning disable xUnit1051
using MessagePack;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Tests;
/// <summary>
/// Wire-parity tests for the client-side IPC contracts (Contracts.cs + Framing.cs).
/// These tests pin the MessagePack byte representation of each DTO using known inputs
/// and assert byte-equality against expected values. Because the sidecar (.NET 4.8)
/// carries a byte-identical mirror of these DTOs, a silent <c>[Key]</c> index drift or
/// field-type change in either copy would cause a mismatch here and be caught at build
/// time — without needing to reference the net48 sidecar assembly from a net10 test
/// project (which the TFM mismatch prevents). (Finding 009.)
/// </summary>
public sealed class ContractsWireParityTests
{
// ---- HistorianSampleDto ----
// Fields at Key(0)=ValueBytes(null), Key(1)=Quality(0), Key(2)=TimestampUtcTicks(0)
// MessagePack fixarray(3) + nil + fixint(0) + fixint(0) = 93 c0 00 00
/// <summary>Verifies that HistorianSampleDto serialized bytes are stable.</summary>
[Fact]
public void HistorianSampleDto_SerializedBytes_AreStable()
{
var dto = new HistorianSampleDto { ValueBytes = null, Quality = 0, TimestampUtcTicks = 0 };
var bytes = MessagePackSerializer.Serialize(dto);
// fixarray(3) = 0x93, nil = 0xC0, fixint(0) = 0x00, fixint(0) = 0x00
bytes.ShouldBe(new byte[] { 0x93, 0xC0, 0x00, 0x00 });
}
/// <summary>Verifies that HistorianSampleDto with value round-trips correctly.</summary>
[Fact]
public void HistorianSampleDto_WithValue_RoundTrips()
{
var original = new HistorianSampleDto
{
ValueBytes = MessagePackSerializer.Serialize<object>(42.5),
Quality = 192,
TimestampUtcTicks = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).Ticks,
};
var bytes = MessagePackSerializer.Serialize(original);
var roundTripped = MessagePackSerializer.Deserialize<HistorianSampleDto>(bytes);
roundTripped.Quality.ShouldBe((byte)192);
roundTripped.TimestampUtcTicks.ShouldBe(original.TimestampUtcTicks);
roundTripped.ValueBytes.ShouldBe(original.ValueBytes);
}
// ---- HistorianAggregateSampleDto ----
// Key(0)=Value(null), Key(1)=TimestampUtcTicks(0)
// fixarray(2) + nil + fixint(0) = 92 c0 00
/// <summary>Verifies that HistorianAggregateSampleDto serialized bytes are stable.</summary>
[Fact]
public void HistorianAggregateSampleDto_SerializedBytes_AreStable()
{
var dto = new HistorianAggregateSampleDto { Value = null, TimestampUtcTicks = 0 };
var bytes = MessagePackSerializer.Serialize(dto);
// fixarray(2) = 0x92, nil = 0xC0, fixint(0) = 0x00
bytes.ShouldBe(new byte[] { 0x92, 0xC0, 0x00 });
}
// ---- ReadRawRequest ----
// 5 fields at Key(0..4). TagName="", StartUtcTicks=0, EndUtcTicks=0, MaxValues=0, CorrelationId=""
// fixarray(5) + fixstr(0)="" + fixint(0) + fixint(0) + fixint(0) + fixstr(0)=""
/// <summary>Verifies that an empty ReadRawRequest serializes as a fixed array of 5 elements.</summary>
[Fact]
public void ReadRawRequest_EmptyInstance_SerializesAsFixArray5()
{
var req = new ReadRawRequest();
var bytes = MessagePackSerializer.Serialize(req);
// Should start with fixarray(5) = 0x95
bytes[0].ShouldBe((byte)0x95);
// Round-trip verification
var rt = MessagePackSerializer.Deserialize<ReadRawRequest>(bytes);
rt.TagName.ShouldBe(string.Empty);
rt.MaxValues.ShouldBe(0);
}
/// <summary>Verifies that ReadRawRequest with values round-trips correctly.</summary>
[Fact]
public void ReadRawRequest_WithValues_RoundTrips()
{
var original = new ReadRawRequest
{
TagName = "Tank.Level",
StartUtcTicks = 100L,
EndUtcTicks = 200L,
MaxValues = 500,
CorrelationId = "abc",
};
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<ReadRawRequest>(bytes);
rt.TagName.ShouldBe("Tank.Level");
rt.StartUtcTicks.ShouldBe(100L);
rt.EndUtcTicks.ShouldBe(200L);
rt.MaxValues.ShouldBe(500);
rt.CorrelationId.ShouldBe("abc");
}
// ---- ReadRawReply ----
/// <summary>Verifies that ReadRawReply round-trips correctly.</summary>
[Fact]
public void ReadRawReply_RoundTrips()
{
var original = new ReadRawReply
{
CorrelationId = "x",
Success = true,
Error = null,
Samples = [new HistorianSampleDto { Quality = 192, TimestampUtcTicks = 99L }],
};
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<ReadRawReply>(bytes);
rt.CorrelationId.ShouldBe("x");
rt.Success.ShouldBeTrue();
rt.Error.ShouldBeNull();
rt.Samples.Length.ShouldBe(1);
rt.Samples[0].Quality.ShouldBe((byte)192);
rt.Samples[0].TimestampUtcTicks.ShouldBe(99L);
}
// ---- ReadAtTimeRequest / ReadAtTimeReply ----
/// <summary>Verifies that ReadAtTimeRequest round-trips correctly.</summary>
[Fact]
public void ReadAtTimeRequest_RoundTrips()
{
var ticks = new long[] { 100L, 200L, 300L };
var original = new ReadAtTimeRequest { TagName = "T", TimestampsUtcTicks = ticks, CorrelationId = "c" };
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<ReadAtTimeRequest>(bytes);
rt.TagName.ShouldBe("T");
rt.TimestampsUtcTicks.ShouldBe(ticks);
rt.CorrelationId.ShouldBe("c");
}
// ---- WriteAlarmEventsRequest / WriteAlarmEventsReply ----
/// <summary>Verifies that WriteAlarmEventsRequest round-trips correctly.</summary>
[Fact]
public void WriteAlarmEventsRequest_RoundTrips()
{
var original = new WriteAlarmEventsRequest
{
Events =
[
new AlarmHistorianEventDto
{
EventId = "ev1",
SourceName = "Tank/HiHi",
ConditionId = "HiHi",
AlarmType = "LimitAlarm:Activated",
Message = "msg",
Severity = 700,
EventTimeUtcTicks = 999L,
AckComment = null,
},
],
CorrelationId = "r",
};
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<WriteAlarmEventsRequest>(bytes);
rt.CorrelationId.ShouldBe("r");
rt.Events.Length.ShouldBe(1);
rt.Events[0].EventId.ShouldBe("ev1");
rt.Events[0].SourceName.ShouldBe("Tank/HiHi");
rt.Events[0].Severity.ShouldBe((ushort)700);
rt.Events[0].EventTimeUtcTicks.ShouldBe(999L);
}
/// <summary>Verifies that WriteAlarmEventsReply round-trips correctly (legacy PerEventOk path).</summary>
[Fact]
public void WriteAlarmEventsReply_RoundTrips()
{
var original = new WriteAlarmEventsReply
{
CorrelationId = "r",
Success = true,
Error = null,
PerEventOk = [true, false, true],
};
var bytes = MessagePackSerializer.Serialize(original);
var rt = MessagePackSerializer.Deserialize<WriteAlarmEventsReply>(bytes);
rt.CorrelationId.ShouldBe("r");
rt.Success.ShouldBeTrue();
rt.PerEventOk.ShouldBe(new[] { true, false, true });
}
/// <summary>
/// Pins the <c>[Key(4)]</c> index for <see cref="WriteAlarmEventsReply.PerEventStatus"/>,
/// the additive granular status field added in the <c>feddc2b8</c> commit. A silent
/// Key-index drift in either the client or the sidecar mirror copy would swap the legacy
/// <c>PerEventOk</c> bool array and the new status byte array, misclassifying outcomes
/// at runtime. (Finding 013.)
/// </summary>
[Fact]
public void WriteAlarmEventsReply_PerEventStatus_IsAtKey4_AndRoundTrips()
{
var original = new WriteAlarmEventsReply
{
CorrelationId = "s",
Success = true,
PerEventOk = [true],
PerEventStatus = [0, 1, 2], // Ack, Retry, Permanent
};
var bytes = MessagePackSerializer.Serialize(original);
// The array must start with fixarray(5) — five keys at indices 0-4.
bytes[0].ShouldBe((byte)0x95, "WriteAlarmEventsReply must be a 5-field MessagePack array");
var rt = MessagePackSerializer.Deserialize<WriteAlarmEventsReply>(bytes);
rt.CorrelationId.ShouldBe("s");
rt.Success.ShouldBeTrue();
rt.PerEventOk.ShouldBe(new[] { true });
// Key(4): PerEventStatus must round-trip independently of Key(3): PerEventOk.
rt.PerEventStatus.ShouldBe(new byte[] { 0, 1, 2 });
}
// ---- MessageKind enum values are pinned ----
// Changing a MessageKind value is a wire break; pin them explicitly.
/// <summary>Verifies that MessageKind enum values are stable.</summary>
[Fact]
public void MessageKind_Values_AreStable()
{
((byte)MessageKind.Hello).ShouldBe((byte)0x01);
((byte)MessageKind.HelloAck).ShouldBe((byte)0x02);
((byte)MessageKind.ReadRawRequest).ShouldBe((byte)0x10);
((byte)MessageKind.ReadRawReply).ShouldBe((byte)0x11);
((byte)MessageKind.ReadProcessedRequest).ShouldBe((byte)0x12);
((byte)MessageKind.ReadProcessedReply).ShouldBe((byte)0x13);
((byte)MessageKind.ReadAtTimeRequest).ShouldBe((byte)0x14);
((byte)MessageKind.ReadAtTimeReply).ShouldBe((byte)0x15);
((byte)MessageKind.ReadEventsRequest).ShouldBe((byte)0x16);
((byte)MessageKind.ReadEventsReply).ShouldBe((byte)0x17);
((byte)MessageKind.WriteAlarmEventsRequest).ShouldBe((byte)0x20);
((byte)MessageKind.WriteAlarmEventsReply).ShouldBe((byte)0x21);
}
// ---- Framing constants are pinned ----
/// <summary>Verifies that framing constants are stable.</summary>
[Fact]
public void Framing_Constants_AreStable()
{
Framing.LengthPrefixSize.ShouldBe(4);
Framing.KindByteSize.ShouldBe(1);
Framing.MaxFrameBodyBytes.ShouldBe(16 * 1024 * 1024);
}
}