6060d21995
ClientProtoInputTests.Descriptor_ContainsEveryContractMessageAndField only
compared messages and fields, and only enumerated the gateway/worker
descriptors, so a new enum value, a new RPC, or any galaxy_repository.proto-only
change would not redden the test even though it is documented as the primary
protoc-free CI gate.
Rename to Descriptor_ContainsEveryContractSymbol and extend the reflection walk
on both sides (published protoset and in-process contract) to also collect
enums/enum values ({enumFullName}, {enumFullName}/{valueName}) and
services/methods ({serviceFullName}, {serviceFullName}/{methodName}), and add
GalaxyRepositoryReflection.Descriptor to the enumerated files. The comparison
stays a flat, order-insensitive string-set diff with no protoc dependency.
Update docs/ClientProtoGeneration.md and docs/Contracts.md prose from
"message or field" to the full symbol coverage.
Red-path proof: pointed the test at the pre-IPC-01 stale protoset and confirmed
it failed naming max_frame_bytes, several MxCommandKind/AlarmProviderMode enum
values, MxAccessGateway/StreamAlarms and GalaxyRepository/BrowseChildren, and
the galaxy_repository.v1.* surface; restored the real path and re-ran green.
Flips IPC-27 to Done in the 2026-07-12 remediation tracker and register.
297 lines
11 KiB
C#
297 lines
11 KiB
C#
using System.Text.Json;
|
|
using Google.Protobuf;
|
|
using Google.Protobuf.Reflection;
|
|
using ZB.MOM.WW.MxGateway.Contracts;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Tests.Contracts;
|
|
|
|
public sealed class ClientProtoInputTests
|
|
{
|
|
/// <summary>
|
|
/// Guards the published client descriptor set against silent staleness. Every message,
|
|
/// field, enum, enum value, service, and method compiled into the in-process contract
|
|
/// (which the build regenerates from the current <c>.proto</c> sources) must appear in the
|
|
/// committed protoset. A missing symbol means the descriptor was not regenerated after a
|
|
/// proto change; run <c>scripts/publish-client-proto-inputs.ps1</c> and commit the
|
|
/// refreshed protoset.
|
|
/// The check is semantic (symbol presence) rather than byte-wise, so it is independent of protoc
|
|
/// version and does not require protoc on the test runner.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Descriptor_ContainsEveryContractSymbol()
|
|
{
|
|
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
|
string descriptorPath = Path.Combine(
|
|
repositoryRoot.FullName,
|
|
"clients",
|
|
"proto",
|
|
"descriptors",
|
|
"mxaccessgw-client-v1.protoset");
|
|
|
|
Assert.True(File.Exists(descriptorPath), $"Expected descriptor set '{descriptorPath}' to exist.");
|
|
|
|
FileDescriptorSet descriptorSet = FileDescriptorSet.Parser.ParseFrom(File.ReadAllBytes(descriptorPath));
|
|
|
|
HashSet<string> publishedMessages = new(StringComparer.Ordinal);
|
|
HashSet<string> publishedFields = new(StringComparer.Ordinal);
|
|
HashSet<string> publishedEnums = new(StringComparer.Ordinal);
|
|
HashSet<string> publishedServices = new(StringComparer.Ordinal);
|
|
foreach (FileDescriptorProto file in descriptorSet.File)
|
|
{
|
|
foreach (DescriptorProto message in file.MessageType)
|
|
{
|
|
CollectPublishedSymbols(file.Package, message, publishedMessages, publishedFields, publishedEnums);
|
|
}
|
|
|
|
foreach (EnumDescriptorProto enumType in file.EnumType)
|
|
{
|
|
CollectPublishedEnumSymbols(file.Package, enumType, publishedEnums);
|
|
}
|
|
|
|
foreach (ServiceDescriptorProto service in file.Service)
|
|
{
|
|
string serviceFullName = string.IsNullOrEmpty(file.Package) ? service.Name : file.Package + "." + service.Name;
|
|
publishedServices.Add(serviceFullName);
|
|
foreach (MethodDescriptorProto method in service.Method)
|
|
{
|
|
publishedServices.Add(serviceFullName + "/" + method.Name);
|
|
}
|
|
}
|
|
}
|
|
|
|
List<string> missing = [];
|
|
FileDescriptor[] contractFiles =
|
|
[
|
|
MxaccessGatewayReflection.Descriptor,
|
|
MxaccessWorkerReflection.Descriptor,
|
|
GalaxyRepositoryReflection.Descriptor,
|
|
];
|
|
foreach (FileDescriptor file in contractFiles)
|
|
{
|
|
foreach (MessageDescriptor message in file.MessageTypes)
|
|
{
|
|
CollectMissingContractSymbols(message, publishedMessages, publishedFields, publishedEnums, missing);
|
|
}
|
|
|
|
foreach (EnumDescriptor enumType in file.EnumTypes)
|
|
{
|
|
CollectMissingEnumSymbols(enumType, publishedEnums, missing);
|
|
}
|
|
|
|
foreach (ServiceDescriptor service in file.Services)
|
|
{
|
|
if (!publishedServices.Contains(service.FullName))
|
|
{
|
|
missing.Add(service.FullName);
|
|
}
|
|
|
|
foreach (MethodDescriptor method in service.Methods)
|
|
{
|
|
string key = service.FullName + "/" + method.Name;
|
|
if (!publishedServices.Contains(key))
|
|
{
|
|
missing.Add(key);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Assert.True(
|
|
missing.Count == 0,
|
|
"Published client descriptor is stale; regenerate with scripts/publish-client-proto-inputs.ps1 and commit. "
|
|
+ "Missing symbols: "
|
|
+ string.Join(", ", missing));
|
|
}
|
|
|
|
private static void CollectPublishedSymbols(
|
|
string package,
|
|
DescriptorProto message,
|
|
HashSet<string> messages,
|
|
HashSet<string> fields,
|
|
HashSet<string> enums)
|
|
{
|
|
string fullName = string.IsNullOrEmpty(package) ? message.Name : package + "." + message.Name;
|
|
messages.Add(fullName);
|
|
|
|
foreach (FieldDescriptorProto field in message.Field)
|
|
{
|
|
fields.Add(fullName + "/" + field.Name);
|
|
}
|
|
|
|
foreach (DescriptorProto nested in message.NestedType)
|
|
{
|
|
CollectPublishedSymbols(fullName, nested, messages, fields, enums);
|
|
}
|
|
|
|
foreach (EnumDescriptorProto enumType in message.EnumType)
|
|
{
|
|
CollectPublishedEnumSymbols(fullName, enumType, enums);
|
|
}
|
|
}
|
|
|
|
private static void CollectPublishedEnumSymbols(
|
|
string containingScope,
|
|
EnumDescriptorProto enumType,
|
|
HashSet<string> enums)
|
|
{
|
|
string enumFullName = string.IsNullOrEmpty(containingScope) ? enumType.Name : containingScope + "." + enumType.Name;
|
|
enums.Add(enumFullName);
|
|
|
|
foreach (EnumValueDescriptorProto value in enumType.Value)
|
|
{
|
|
enums.Add(enumFullName + "/" + value.Name);
|
|
}
|
|
}
|
|
|
|
private static void CollectMissingContractSymbols(
|
|
MessageDescriptor message,
|
|
HashSet<string> publishedMessages,
|
|
HashSet<string> publishedFields,
|
|
HashSet<string> publishedEnums,
|
|
List<string> missing)
|
|
{
|
|
if (!publishedMessages.Contains(message.FullName))
|
|
{
|
|
missing.Add(message.FullName);
|
|
}
|
|
|
|
foreach (FieldDescriptor field in message.Fields.InDeclarationOrder())
|
|
{
|
|
string key = message.FullName + "/" + field.Name;
|
|
if (!publishedFields.Contains(key))
|
|
{
|
|
missing.Add(key);
|
|
}
|
|
}
|
|
|
|
foreach (MessageDescriptor nested in message.NestedTypes)
|
|
{
|
|
CollectMissingContractSymbols(nested, publishedMessages, publishedFields, publishedEnums, missing);
|
|
}
|
|
|
|
foreach (EnumDescriptor enumType in message.EnumTypes)
|
|
{
|
|
CollectMissingEnumSymbols(enumType, publishedEnums, missing);
|
|
}
|
|
}
|
|
|
|
private static void CollectMissingEnumSymbols(
|
|
EnumDescriptor enumType,
|
|
HashSet<string> publishedEnums,
|
|
List<string> missing)
|
|
{
|
|
if (!publishedEnums.Contains(enumType.FullName))
|
|
{
|
|
missing.Add(enumType.FullName);
|
|
}
|
|
|
|
foreach (EnumValueDescriptor value in enumType.Values)
|
|
{
|
|
string key = enumType.FullName + "/" + value.Name;
|
|
if (!publishedEnums.Contains(key))
|
|
{
|
|
missing.Add(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the proto inputs manifest declares current protocol versions and existing source files.</summary>
|
|
[Fact]
|
|
public void Manifest_DeclaresCurrentProtocolVersionsAndExistingInputs()
|
|
{
|
|
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
|
string manifestPath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "proto-inputs.json");
|
|
|
|
using JsonDocument manifest = JsonDocument.Parse(File.ReadAllText(manifestPath));
|
|
JsonElement root = manifest.RootElement;
|
|
|
|
Assert.Equal(1, root.GetProperty("schemaVersion").GetInt32());
|
|
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, root.GetProperty("gatewayProtocolVersion").GetUInt32());
|
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, root.GetProperty("workerProtocolVersion").GetUInt32());
|
|
|
|
string protoRoot = Path.Combine(repositoryRoot.FullName, root.GetProperty("protoRoot").GetString()!);
|
|
foreach (JsonElement sourceFile in root.GetProperty("sourceFiles").EnumerateArray())
|
|
{
|
|
string sourcePath = Path.Combine(protoRoot, sourceFile.GetProperty("path").GetString()!);
|
|
Assert.True(File.Exists(sourcePath), $"Expected proto source file '{sourcePath}' to exist.");
|
|
}
|
|
|
|
foreach (JsonProperty output in root.GetProperty("generatedOutputs").EnumerateObject())
|
|
{
|
|
string outputPath = Path.Combine(repositoryRoot.FullName, output.Value.GetString()!);
|
|
Assert.True(Directory.Exists(outputPath), $"Expected generated output directory '{outputPath}' to exist.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Verifies that the OpenSessionReply fixture parses with the current contract version.</summary>
|
|
[Fact]
|
|
public void OpenSessionReplyFixture_ParsesWithCurrentContract()
|
|
{
|
|
OpenSessionReply reply = ParseFixture(
|
|
"open-session-reply.ok.json",
|
|
OpenSessionReply.Parser);
|
|
|
|
Assert.Equal(GatewayContractInfo.GatewayProtocolVersion, reply.GatewayProtocolVersion);
|
|
Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, reply.WorkerProtocolVersion);
|
|
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
|
}
|
|
|
|
/// <summary>Verifies that the RegisterCommand fixture parses with the current contract version.</summary>
|
|
[Fact]
|
|
public void RegisterCommandRequestFixture_ParsesWithCurrentContract()
|
|
{
|
|
MxCommandRequest request = ParseFixture(
|
|
"register-command-request.json",
|
|
MxCommandRequest.Parser);
|
|
|
|
Assert.Equal(MxCommandKind.Register, request.Command.Kind);
|
|
Assert.Equal("fixture-client", request.Command.Register.ClientName);
|
|
}
|
|
|
|
/// <summary>Verifies that the OnDataChange event fixture parses with the current contract version.</summary>
|
|
[Fact]
|
|
public void OnDataChangeEventFixture_ParsesWithCurrentContract()
|
|
{
|
|
MxEvent gatewayEvent = ParseFixture(
|
|
"on-data-change-event.json",
|
|
MxEvent.Parser);
|
|
|
|
Assert.Equal(MxEventFamily.OnDataChange, gatewayEvent.Family);
|
|
Assert.Equal(1ul, gatewayEvent.WorkerSequence);
|
|
Assert.Equal(MxDataType.Integer, gatewayEvent.Value.DataType);
|
|
Assert.Equal(MxEvent.BodyOneofCase.OnDataChange, gatewayEvent.BodyCase);
|
|
}
|
|
|
|
private static T ParseFixture<T>(
|
|
string fixtureName,
|
|
MessageParser<T> parser)
|
|
where T : IMessage<T>
|
|
{
|
|
DirectoryInfo repositoryRoot = FindRepositoryRoot();
|
|
string fixturePath = Path.Combine(repositoryRoot.FullName, "clients", "proto", "fixtures", "golden", fixtureName);
|
|
|
|
return parser.ParseJson(File.ReadAllText(fixturePath));
|
|
}
|
|
|
|
private static DirectoryInfo FindRepositoryRoot()
|
|
{
|
|
DirectoryInfo? current = new(AppContext.BaseDirectory);
|
|
|
|
while (current is not null)
|
|
{
|
|
if (File.Exists(Path.Combine(current.FullName, "CLAUDE.md"))
|
|
&& Directory.Exists(Path.Combine(current.FullName, "src"))
|
|
&& Directory.Exists(Path.Combine(current.FullName, "clients")))
|
|
{
|
|
return current;
|
|
}
|
|
|
|
current = current.Parent;
|
|
}
|
|
|
|
throw new DirectoryNotFoundException("Could not locate the repository root from the test output directory.");
|
|
}
|
|
}
|