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_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) { byte[] header = new byte[6]; BinaryPrimitives.WriteUInt16LittleEndian(header.AsSpan(0, 2), HistorianEventRowProtocol.EventRowProtocolVersion); BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(2, 4), rowCount); 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; } byte[] row = new byte[4 + 2 + 8 + 16 + eventTypeBytes.Length + 2 + propertyBlockSize]; Span span = row; BinaryPrimitives.WriteUInt32LittleEndian(span[..4], HistorianEventRowProtocol.RowMarker); BinaryPrimitives.WriteUInt16LittleEndian(span.Slice(4, 2), HistorianEventRowProtocol.RowFormatV9); BinaryPrimitives.WriteInt64LittleEndian(span.Slice(6, 8), eventTimeUtc.ToFileTimeUtc()); // 16 bytes of zeroed slot ushorts left as-is. int eventTypeOffset = 4 + 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; } }