feat(lmxproxy): phase 1 — v2 protocol types and domain model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-03-21 23:41:56 -04:00
parent 08d2a07d8b
commit 0d63fb1105
87 changed files with 3389 additions and 956 deletions

View File

@@ -0,0 +1,270 @@
using System.IO;
using FluentAssertions;
using Google.Protobuf;
using ProtoBuf;
using Xunit;
using ProtoGenerated = Scada;
using CodeFirst = ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.CrossStack;
/// <summary>
/// Verifies wire compatibility between Host proto-generated types and Client code-first types.
/// Serializes with one stack, deserializes with the other.
/// </summary>
public class CrossStackSerializationTests
{
// ── Proto-generated → Code-first ──────────────────────────
[Fact]
public void VtqMessage_ProtoToCodeFirst_BoolValue()
{
// Arrange: proto-generated VtqMessage with bool TypedValue
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Running",
Value = new ProtoGenerated.TypedValue { BoolValue = true },
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
// Act: serialize with proto, deserialize with protobuf-net
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
// Assert
codeFirst.Should().NotBeNull();
codeFirst.Tag.Should().Be("Motor.Running");
codeFirst.Value.Should().NotBeNull();
codeFirst.Value!.BoolValue.Should().BeTrue();
codeFirst.TimestampUtcTicks.Should().Be(638789000000000000L);
codeFirst.Quality.Should().NotBeNull();
codeFirst.Quality!.StatusCode.Should().Be(0x00000000u);
codeFirst.Quality.SymbolicName.Should().Be("Good");
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_DoubleValue()
{
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Speed",
Value = new ProtoGenerated.TypedValue { DoubleValue = 42.5 },
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Value.Should().NotBeNull();
codeFirst.Value!.DoubleValue.Should().Be(42.5);
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_StringValue()
{
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Name",
Value = new ProtoGenerated.TypedValue { StringValue = "Pump A" },
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Value.Should().NotBeNull();
codeFirst.Value!.StringValue.Should().Be("Pump A");
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_Int32Value()
{
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Count",
Value = new ProtoGenerated.TypedValue { Int32Value = 2147483647 },
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Value!.Int32Value.Should().Be(int.MaxValue);
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_BadQuality()
{
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Fault",
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Quality!.StatusCode.Should().Be(0x806D0000u);
codeFirst.Quality.SymbolicName.Should().Be("BadSensorFailure");
codeFirst.Quality.IsBad.Should().BeTrue();
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_NullValue()
{
// No Value field set — represents null
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Optional",
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
// When no oneof is set, the Value object may be null or all-default
// Either way, GetValueCase() should return None
if (codeFirst.Value != null)
codeFirst.Value.GetValueCase().Should().Be(CodeFirst.TypedValueCase.None);
}
[Fact]
public void VtqMessage_ProtoToCodeFirst_FloatArrayValue()
{
var floatArr = new ProtoGenerated.FloatArray();
floatArr.Values.AddRange(new[] { 1.0f, 2.0f, 3.0f });
var protoMsg = new ProtoGenerated.VtqMessage
{
Tag = "Motor.Samples",
Value = new ProtoGenerated.TypedValue
{
ArrayValue = new ProtoGenerated.ArrayValue { FloatValues = floatArr }
},
TimestampUtcTicks = 638789000000000000L,
Quality = new ProtoGenerated.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.VtqMessage>(new MemoryStream(bytes));
codeFirst.Value.Should().NotBeNull();
codeFirst.Value!.ArrayValue.Should().NotBeNull();
codeFirst.Value.ArrayValue!.FloatValues.Should().NotBeNull();
codeFirst.Value.ArrayValue.FloatValues!.Values.Should().BeEquivalentTo(new[] { 1.0f, 2.0f, 3.0f });
}
// ── Code-first → Proto-generated ──────────────────────────
[Fact]
public void VtqMessage_CodeFirstToProto_DoubleValue()
{
var codeFirst = new CodeFirst.VtqMessage
{
Tag = "Motor.Speed",
Value = new CodeFirst.TypedValue { DoubleValue = 99.9 },
TimestampUtcTicks = 638789000000000000L,
Quality = new CodeFirst.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" }
};
// Serialize with protobuf-net
var ms = new MemoryStream();
Serializer.Serialize(ms, codeFirst);
var bytes = ms.ToArray();
// Deserialize with Google.Protobuf
var protoMsg = ProtoGenerated.VtqMessage.Parser.ParseFrom(bytes);
protoMsg.Tag.Should().Be("Motor.Speed");
protoMsg.Value.Should().NotBeNull();
protoMsg.Value.DoubleValue.Should().Be(99.9);
protoMsg.TimestampUtcTicks.Should().Be(638789000000000000L);
protoMsg.Quality.StatusCode.Should().Be(0x00000000u);
}
[Fact]
public void WriteRequest_CodeFirstToProto()
{
var codeFirst = new CodeFirst.WriteRequest
{
SessionId = "abc123",
Tag = "Motor.Speed",
Value = new CodeFirst.TypedValue { DoubleValue = 42.5 }
};
var ms = new MemoryStream();
Serializer.Serialize(ms, codeFirst);
var bytes = ms.ToArray();
var protoMsg = ProtoGenerated.WriteRequest.Parser.ParseFrom(bytes);
protoMsg.SessionId.Should().Be("abc123");
protoMsg.Tag.Should().Be("Motor.Speed");
protoMsg.Value.Should().NotBeNull();
protoMsg.Value.DoubleValue.Should().Be(42.5);
}
[Fact]
public void ConnectRequest_RoundTrips()
{
var codeFirst = new CodeFirst.ConnectRequest { ClientId = "ScadaLink-1", ApiKey = "key-123" };
var ms = new MemoryStream();
Serializer.Serialize(ms, codeFirst);
var protoMsg = ProtoGenerated.ConnectRequest.Parser.ParseFrom(ms.ToArray());
protoMsg.ClientId.Should().Be("ScadaLink-1");
protoMsg.ApiKey.Should().Be("key-123");
}
[Fact]
public void ConnectResponse_RoundTrips()
{
var protoMsg = new ProtoGenerated.ConnectResponse
{
Success = true,
Message = "Connected",
SessionId = "abcdef1234567890abcdef1234567890"
};
var bytes = protoMsg.ToByteArray();
var codeFirst = Serializer.Deserialize<CodeFirst.ConnectResponse>(new MemoryStream(bytes));
codeFirst.Success.Should().BeTrue();
codeFirst.Message.Should().Be("Connected");
codeFirst.SessionId.Should().Be("abcdef1234567890abcdef1234567890");
}
[Fact]
public void WriteBatchAndWaitRequest_CodeFirstToProto_TypedFlagValue()
{
var codeFirst = new CodeFirst.WriteBatchAndWaitRequest
{
SessionId = "sess1",
FlagTag = "Motor.Done",
FlagValue = new CodeFirst.TypedValue { BoolValue = true },
TimeoutMs = 5000,
PollIntervalMs = 100,
Items =
{
new CodeFirst.WriteItem
{
Tag = "Motor.Speed",
Value = new CodeFirst.TypedValue { DoubleValue = 50.0 }
}
}
};
var ms = new MemoryStream();
Serializer.Serialize(ms, codeFirst);
var protoMsg = ProtoGenerated.WriteBatchAndWaitRequest.Parser.ParseFrom(ms.ToArray());
protoMsg.FlagTag.Should().Be("Motor.Done");
protoMsg.FlagValue.BoolValue.Should().BeTrue();
protoMsg.TimeoutMs.Should().Be(5000);
protoMsg.PollIntervalMs.Should().Be(100);
protoMsg.Items.Should().HaveCount(1);
protoMsg.Items[0].Tag.Should().Be("Motor.Speed");
protoMsg.Items[0].Value.DoubleValue.Should().Be(50.0);
}
}

View File

@@ -0,0 +1,29 @@
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain;
public class QualityExtensionsTests
{
[Theory]
[InlineData(Quality.Good, true)]
[InlineData(Quality.Good_LocalOverride, true)]
[InlineData(Quality.Uncertain, false)]
[InlineData(Quality.Bad, false)]
public void IsGood(Quality q, bool expected) => q.IsGood().Should().Be(expected);
[Theory]
[InlineData(Quality.Uncertain, true)]
[InlineData(Quality.Uncertain_LastUsable, true)]
[InlineData(Quality.Good, false)]
[InlineData(Quality.Bad, false)]
public void IsUncertain(Quality q, bool expected) => q.IsUncertain().Should().Be(expected);
[Theory]
[InlineData(Quality.Bad, true)]
[InlineData(Quality.Bad_CommFailure, true)]
[InlineData(Quality.Good, false)]
[InlineData(Quality.Uncertain, false)]
public void IsBad(Quality q, bool expected) => q.IsBad().Should().Be(expected);
}

View File

@@ -0,0 +1,134 @@
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain;
public class ScadaContractsTests
{
[Fact]
public void TypedValue_GetValueCase_Bool()
{
var tv = new TypedValue { BoolValue = true };
tv.GetValueCase().Should().Be(TypedValueCase.BoolValue);
}
[Fact]
public void TypedValue_GetValueCase_Int32()
{
var tv = new TypedValue { Int32Value = 42 };
tv.GetValueCase().Should().Be(TypedValueCase.Int32Value);
}
[Fact]
public void TypedValue_GetValueCase_Double()
{
var tv = new TypedValue { DoubleValue = 3.14 };
tv.GetValueCase().Should().Be(TypedValueCase.DoubleValue);
}
[Fact]
public void TypedValue_GetValueCase_String()
{
var tv = new TypedValue { StringValue = "hello" };
tv.GetValueCase().Should().Be(TypedValueCase.StringValue);
}
[Fact]
public void TypedValue_GetValueCase_None_WhenDefault()
{
var tv = new TypedValue();
tv.GetValueCase().Should().Be(TypedValueCase.None);
}
[Fact]
public void TypedValue_GetValueCase_Datetime()
{
var tv = new TypedValue { DatetimeValue = DateTime.UtcNow.Ticks };
tv.GetValueCase().Should().Be(TypedValueCase.DatetimeValue);
}
[Fact]
public void TypedValue_GetValueCase_BytesValue()
{
var tv = new TypedValue { BytesValue = new byte[] { 1, 2, 3 } };
tv.GetValueCase().Should().Be(TypedValueCase.BytesValue);
}
[Fact]
public void TypedValue_GetValueCase_ArrayValue()
{
var tv = new TypedValue
{
ArrayValue = new ArrayValue
{
FloatValues = new FloatArray { Values = { 1.0f, 2.0f } }
}
};
tv.GetValueCase().Should().Be(TypedValueCase.ArrayValue);
}
[Fact]
public void QualityCode_IsGood()
{
var qc = new QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" };
qc.IsGood.Should().BeTrue();
qc.IsBad.Should().BeFalse();
qc.IsUncertain.Should().BeFalse();
}
[Fact]
public void QualityCode_IsBad()
{
var qc = new QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" };
qc.IsGood.Should().BeFalse();
qc.IsBad.Should().BeTrue();
qc.IsUncertain.Should().BeFalse();
}
[Fact]
public void QualityCode_IsUncertain()
{
var qc = new QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" };
qc.IsGood.Should().BeFalse();
qc.IsBad.Should().BeFalse();
qc.IsUncertain.Should().BeTrue();
}
[Fact]
public void VtqMessage_DefaultProperties()
{
var vtq = new VtqMessage();
vtq.Tag.Should().BeEmpty();
vtq.Value.Should().BeNull();
vtq.TimestampUtcTicks.Should().Be(0);
vtq.Quality.Should().BeNull();
}
[Fact]
public void WriteBatchAndWaitRequest_FlagValue_IsTypedValue()
{
var req = new WriteBatchAndWaitRequest
{
SessionId = "abc",
FlagTag = "Motor.Done",
FlagValue = new TypedValue { BoolValue = true },
TimeoutMs = 5000,
PollIntervalMs = 100
};
req.FlagValue.Should().NotBeNull();
req.FlagValue!.GetValueCase().Should().Be(TypedValueCase.BoolValue);
}
[Fact]
public void WriteItem_Value_IsTypedValue()
{
var item = new WriteItem
{
Tag = "Motor.Speed",
Value = new TypedValue { DoubleValue = 42.5 }
};
item.Value.Should().NotBeNull();
item.Value!.GetValueCase().Should().Be(TypedValueCase.DoubleValue);
}
}

View File

@@ -0,0 +1,33 @@
using FluentAssertions;
using Xunit;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client.Tests.Domain;
public class VtqTests
{
[Fact]
public void Good_FactoryMethod()
{
var vtq = Vtq.Good(42.0);
vtq.Value.Should().Be(42.0);
vtq.Quality.Should().Be(Quality.Good);
vtq.Timestamp.Kind.Should().Be(DateTimeKind.Utc);
}
[Fact]
public void Bad_FactoryMethod()
{
var vtq = Vtq.Bad();
vtq.Value.Should().BeNull();
vtq.Quality.Should().Be(Quality.Bad);
}
[Fact]
public void Uncertain_FactoryMethod()
{
var vtq = Vtq.Uncertain("stale");
vtq.Value.Should().Be("stale");
vtq.Quality.Should().Be(Quality.Uncertain);
}
}

View File

@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Client.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="7.2.0" />
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
<PackageReference Include="Grpc.Tools" Version="2.68.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.LmxProxy.Client\ZB.MOM.WW.LmxProxy.Client.csproj" />
</ItemGroup>
<!-- Proto file for cross-stack serialization tests (Host proto → Client code-first) -->
<ItemGroup>
<Protobuf Include="..\..\src\ZB.MOM.WW.LmxProxy.Host\Grpc\Protos\scada.proto" GrpcServices="None" Link="Protos\scada.proto" />
</ItemGroup>
</Project>