Add six previously-missing edge-case tests to WonderwareHistorianClientTests: (2) WriteBatchAsync transport-drop catch path returns RetryPlease for all events; (3) InvokeAsync second-attempt-also-fails propagates the exception; (4) stalled sidecar fires OperationCanceledException within CallTimeout; (5) HistoryAggregateType.Total throws NotSupportedException via ReadProcessedAsync; (6) sidecar wrong-MessageKind reply throws InvalidDataException. Extend FakeSidecarServer with DisconnectBeforeReply, ReplyWithWrongKind, and StallAfterRequest test knobs to support these scenarios. Add ContractsWireParityTests.cs (11 tests) to pin the MessagePack byte layout, round-trip correctness, MessageKind enum values, and Framing constants — catching silent [Key] index drift between the client and sidecar mirror copies without requiring a cross-TFM (net10 vs net48) project reference. Test count grew from 11 to 27; all 27 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
223 lines
8.0 KiB
C#
223 lines
8.0 KiB
C#
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
|
|
|
|
[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 });
|
|
}
|
|
|
|
[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
|
|
|
|
[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)=""
|
|
|
|
[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);
|
|
}
|
|
|
|
[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 ----
|
|
|
|
[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 ----
|
|
|
|
[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 ----
|
|
|
|
[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);
|
|
}
|
|
|
|
[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 });
|
|
}
|
|
|
|
// ---- MessageKind enum values are pinned ----
|
|
// Changing a MessageKind value is a wire break; pin them explicitly.
|
|
|
|
[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 ----
|
|
|
|
[Fact]
|
|
public void Framing_Constants_AreStable()
|
|
{
|
|
Framing.LengthPrefixSize.ShouldBe(4);
|
|
Framing.KindByteSize.ShouldBe(1);
|
|
Framing.MaxFrameBodyBytes.ShouldBe(16 * 1024 * 1024);
|
|
}
|
|
}
|