using System.Buffers.Binary; using System.Text; using AVEVA.Historian.Client.Models; using AVEVA.Historian.Client.Wcf; namespace AVEVA.Historian.Client.Tests; public sealed class HistorianEventRowProtocolTests { private static readonly Guid PlaceholderAlarmId = new("00000000-0000-0000-0000-000000000001"); [Fact] public void Parse_EmptyBuffer_ReturnsEmpty() { IReadOnlyList events = HistorianEventRowProtocol.Parse([]); Assert.Empty(events); } [Fact] public void Parse_HeaderWithZeroRowCount_ReturnsEmpty() { byte[] buffer = BuildHeader(rowCount: 0); IReadOnlyList events = HistorianEventRowProtocol.Parse(buffer); Assert.Empty(events); } [Fact] public void Parse_WrongVersion_ReturnsEmpty() { byte[] buffer = new byte[6]; BinaryPrimitives.WriteUInt16LittleEndian(buffer.AsSpan(0, 2), 8); // not 9 BinaryPrimitives.WriteUInt32LittleEndian(buffer.AsSpan(2, 4), 5u); IReadOnlyList events = HistorianEventRowProtocol.Parse(buffer); Assert.Empty(events); } [Fact] public void Parse_TwoSyntheticRows_ReturnsTimestampsAndEventTypes() { DateTime t1 = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); DateTime t2 = t1.AddSeconds(10); byte[] buffer = Concat( BuildHeader(rowCount: 2), BuildRow(t1, "Alarm.Set", []), BuildRow(t2, "Alarm.Clear", [])); IReadOnlyList events = HistorianEventRowProtocol.Parse(buffer); Assert.Equal(2, events.Count); Assert.Equal(t1, events[0].EventTimeUtc); Assert.Equal("Alarm.Set", events[0].Type); Assert.Equal(t2, events[1].EventTimeUtc); Assert.Equal("Alarm.Clear", events[1].Type); } [Fact] public void Parse_RowWithKnownProperties_PopulatesEventFields() { DateTime eventTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); DateTime receivedTime = eventTime.AddMilliseconds(250); var properties = new (string Name, byte[] Value)[] { ("alarm_inalarm", BuildBool(true)), ("alarm_id", BuildGuid(PlaceholderAlarmId)), ("severity", BuildInt32(2)), ("priority", BuildInt32(500)), ("alarm_class", BuildUtf16String("DSC")), ("source_processvariable", BuildUtf16String("Sample.Tag")), ("provider_system", BuildUtf16String("Application Server")), ("receivedtime", BuildFiletime(receivedTime)), ("revisionversion", BuildInt32(7)), }; byte[] buffer = Concat(BuildHeader(rowCount: 1), BuildRow(eventTime, "Alarm.Set", properties)); IReadOnlyList events = HistorianEventRowProtocol.Parse(buffer); HistorianEvent evt = Assert.Single(events); Assert.Equal(PlaceholderAlarmId, evt.Id); Assert.Equal(eventTime, evt.EventTimeUtc); Assert.Equal(receivedTime, evt.ReceivedTimeUtc); Assert.Equal("Alarm.Set", evt.Type); Assert.Equal("Sample.Tag", evt.SourceName); Assert.Equal("Application Server", evt.Namespace); Assert.Equal(7, evt.RevisionVersion); Assert.Equal(true, evt.Properties["alarm_inalarm"]); Assert.Equal("DSC", evt.Properties["alarm_class"]); Assert.Equal(2, evt.Properties["severity"]); Assert.Equal(500, evt.Properties["priority"]); } [Fact] public void Parse_Version11GrpcHeader_ParsesRowsIdenticalToV9() { // 2023 R2 gRPC returns the event-row buffer with container version 11; the per-row layout is // byte-identical to the WCF v9 format. The parser must accept both (verified against a captured // stock-client read of 50 Alarm.Set/Alarm.Clear rows whose header began 0B00 .. 1E000000 0700). DateTime t1 = new(2026, 6, 23, 13, 34, 14, DateTimeKind.Utc); DateTime t2 = t1.AddSeconds(10); byte[] header = BuildHeader(2u, HistorianEventRowProtocol.EventRowProtocolVersionGrpc); // version 11 byte[] buffer = Concat(header, BuildRow(t1, "Alarm.Set", []), BuildRow(t2, "Alarm.Clear", [])); IReadOnlyList events = HistorianEventRowProtocol.Parse(buffer); Assert.Equal(2, events.Count); Assert.Equal("Alarm.Set", events[0].Type); Assert.Equal(t1, events[0].EventTimeUtc); Assert.Equal("Alarm.Clear", events[1].Type); Assert.Equal(t2, events[1].EventTimeUtc); } // Verification against the PROVIDED 2023 R2 client: parse the real GetNextEventQueryResultBuffer // result the stock client received (50 events), proving our read path decodes genuine gRPC event // data. The capture carries customer identity so it is gitignored — point HISTORIAN_EVENT_CAPTURE_NDJSON // at the captured ndjson to run; the test skips cleanly otherwise (no fixture committed). [Fact] public void Parse_RealStockClientCapture_DecodesAllEvents() { string? ndjson = Environment.GetEnvironmentVariable("HISTORIAN_EVENT_CAPTURE_NDJSON"); if (string.IsNullOrWhiteSpace(ndjson) || !File.Exists(ndjson)) { return; // gated: no capture available } byte[]? resultBuffer = null; foreach (string line in File.ReadLines(ndjson)) { if (!line.Contains("GetNextEventQueryResultBuffer.result.out")) continue; int i = line.IndexOf("\"Base64\":\"", StringComparison.Ordinal); if (i < 0) continue; i += "\"Base64\":\"".Length; int j = line.IndexOf('"', i); resultBuffer = Convert.FromBase64String(line.Substring(i, j - i)); break; } Assert.NotNull(resultBuffer); ushort version = BinaryPrimitives.ReadUInt16LittleEndian(resultBuffer.AsSpan(0, 2)); uint rowCount = BinaryPrimitives.ReadUInt32LittleEndian(resultBuffer.AsSpan(2, 4)); Assert.Equal(HistorianEventRowProtocol.EventRowProtocolVersionGrpc, version); // real gRPC buffer is v11 IReadOnlyList events = HistorianEventRowProtocol.Parse(resultBuffer); // Our parser decodes every row the stock client received. Assert.Equal((int)rowCount, events.Count); Assert.All(events, e => { Assert.False(string.IsNullOrEmpty(e.Type)); Assert.NotEqual(default, e.EventTimeUtc); }); // Sanitized cross-check: only the generic AVEVA event types (no customer fields asserted). Assert.All(events, e => Assert.Contains(e.Type, new[] { "Alarm.Set", "Alarm.Clear" })); } [Fact] public void Parse_UnknownTypeMarker_KeepsRawBytesInPropertyBag() { DateTime eventTime = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); // Custom type 0xAA with 3-byte value. byte[] customValue = [0xAA, 0x03, 0x00, 0xDE, 0xAD, 0xBE]; byte[] buffer = Concat( BuildHeader(rowCount: 1), BuildRowWithRawValue(eventTime, "Alarm.Set", "custom_field", customValue)); IReadOnlyList events = HistorianEventRowProtocol.Parse(buffer); HistorianEvent evt = Assert.Single(events); Assert.IsType(evt.Properties["custom_field"]); Assert.Equal([0xDE, 0xAD, 0xBE], (byte[])evt.Properties["custom_field"]!); } [Fact] public void Parse_RowWithMissingMarker_StopsAtBadRow() { DateTime t1 = new(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); byte[] goodRow = BuildRow(t1, "Alarm.Set", []); byte[] badRow = new byte[goodRow.Length]; byte[] buffer = Concat(BuildHeader(rowCount: 2), goodRow, badRow); IReadOnlyList events = HistorianEventRowProtocol.Parse(buffer); Assert.Single(events); Assert.Equal("Alarm.Set", events[0].Type); } private static byte[] BuildHeader(uint rowCount) => BuildHeader(rowCount, HistorianEventRowProtocol.EventRowProtocolVersion); private static byte[] BuildHeader(uint rowCount, ushort version) { // version(2) + rowCount(4) + the single buffer-level header field (0x1E). Rows are markerless. byte[] header = new byte[10]; BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0, 2), version); BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(2, 4), rowCount); BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(6, 4), HistorianEventRowProtocol.BufferHeaderField); return header; } private static byte[] BuildRow(DateTime eventTimeUtc, string eventType, (string Name, byte[] Value)[] properties) { byte[] eventTypeBytes = BuildCompactAscii(eventType); ushort propertyCount = (ushort)properties.Length; int propertyBlockSize = 0; byte[][] propertyBlocks = new byte[properties.Length][]; for (int i = 0; i < properties.Length; i++) { byte[] nameBlock = BuildCompactAscii(properties[i].Name); propertyBlocks[i] = Concat(nameBlock, properties[i].Value); propertyBlockSize += propertyBlocks[i].Length; } // Markerless row: rowFormat(2) + filetime(8) + 8×UInt16 slots(16) + type + propCount + props. byte[] row = new byte[2 + 8 + 16 + eventTypeBytes.Length + 2 + propertyBlockSize]; Span span = row; BinaryPrimitives.WriteUInt16LittleEndian(span[..2], HistorianEventRowProtocol.RowFormat); BinaryPrimitives.WriteInt64LittleEndian(span.Slice(2, 8), eventTimeUtc.ToFileTimeUtc()); // 16 bytes of zeroed slot ushorts left as-is. int eventTypeOffset = 2 + 8 + 16; eventTypeBytes.CopyTo(span[eventTypeOffset..]); BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(eventTypeOffset + eventTypeBytes.Length, 2), propertyCount); int cursor = eventTypeOffset + eventTypeBytes.Length + 2; foreach (byte[] block in propertyBlocks) { block.CopyTo(span[cursor..]); cursor += block.Length; } return row; } private static byte[] BuildRowWithRawValue(DateTime eventTimeUtc, string eventType, string propertyName, byte[] rawValueBytes) { return BuildRow(eventTimeUtc, eventType, [(propertyName, rawValueBytes)]); } private static byte[] BuildCompactAscii(string s) { byte[] ascii = Encoding.ASCII.GetBytes(s); byte[] result = new byte[3 + ascii.Length]; result[0] = 0x09; result[1] = (byte)ascii.Length; result[2] = 0x00; ascii.CopyTo(result, 3); return result; } private static byte[] BuildBool(bool value) => [0x02, 0x01, 0x00, value ? (byte)1 : (byte)0]; private static byte[] BuildInt32(int value) { byte[] result = [0x31, 0x04, 0x00, 0, 0, 0, 0]; BinaryPrimitives.WriteInt32LittleEndian(result.AsSpan(3, 4), value); return result; } private static byte[] BuildGuid(Guid value) { byte[] result = new byte[19]; result[0] = 0x10; result[1] = 0x10; result[2] = 0x00; value.ToByteArray().CopyTo(result, 3); return result; } private static byte[] BuildFiletime(DateTime value) { byte[] result = [0x18, 0x08, 0x00, 0, 0, 0, 0, 0, 0, 0, 0]; BinaryPrimitives.WriteInt64LittleEndian(result.AsSpan(3, 8), value.ToFileTimeUtc()); return result; } private static byte[] BuildUtf16String(string value) { byte[] chars = Encoding.Unicode.GetBytes(value); ushort innerLength = (ushort)(2 + chars.Length); // UInt16 charCount + chars byte[] result = new byte[3 + innerLength]; result[0] = 0x43; result[1] = (byte)innerLength; result[2] = 0x00; BinaryPrimitives.WriteUInt16LittleEndian(result.AsSpan(3, 2), (ushort)value.Length); chars.CopyTo(result, 5); return result; } private static byte[] Concat(params byte[][] arrays) { int total = 0; foreach (byte[] a in arrays) total += a.Length; byte[] result = new byte[total]; int offset = 0; foreach (byte[] a in arrays) { Buffer.BlockCopy(a, 0, result, offset, a.Length); offset += a.Length; } return result; } }