Files
Joseph Doherty 59b13d317b feat(grpc): T1B.1 — site_command.proto + SiteCommandDtoMapper + round-trip goldens
Phase 1B's contract slice: the wire shape and the canonical translation for the
28 central→site commands that leave ClusterClient. No behaviour changes yet —
SiteCommunicationActor and CentralCommunicationActor are untouched; the
dispatcher refactor (T1B.2) and the central transport seam (T1B.3) consume this.

Protos/site_command.proto (package scadabridge.sitecommand.v1, service
SiteCommandService): six domain RPCs, each with a `oneof` request/reply
envelope. The grouping is what carries deadline policy — every command inside a
group shares a CommunicationOptions timeout class today, so one RPC per group
keeps the deadline choice in one place on the client and one dispatch switch on
the server, while the oneof keeps each command individually typed:
ExecuteLifecycle(6) · ExecuteOpcUa(8) · ExecuteQuery(4) · ExecuteParked(5) ·
ExecuteRoute(4) · TriggerFailover(1). IntegrationCallRequest — the 29th entry on
SiteCommunicationActor's receive table — is deliberately excluded as dead code
(2026-07-22-integration-call-routing-is-dead-code.md).

Contract decisions worth knowing:

- Nullable COLLECTIONS ride in per-collection wrapper messages
  (DeployArtifactsCommand's six artifact lists, CertTrustResult.Certs,
  RouteToCallRequest.Parameters). proto3 repeated/map collapses null into empty,
  and that distinction is live at the site — the same silent-data-loss class the
  transport round-trip guard exposed in PLAN-05 T8. Goldens cover null, empty
  and populated for each.
- Nullable strings use the empty-string-means-null convention already set by
  AuditEventDtoMapper, with ONE exception: RouteToWaitForAttributeRequest's
  TargetValueEncoded, where "wait for the empty string" is a real target, so it
  carries a StringValue wrapper. Both behaviours are asserted, not assumed.
- Nullable enums ride in one-field messages (proto3 enums have no presence and
  no stock wrapper). Enum translation is an explicit switch in both directions —
  never by ordinal — so reordering a C# enum cannot re-map the wire; every wire
  enum reserves 0 for _UNSPECIFIED and decodes to a documented safe default
  rather than faulting a command from a version-skewed peer.
- New LooseValueCodec carries the surviving `object?` members (script params and
  return values, attribute values, tag read/write values) as a type-tagged union
  so a boxed value keeps its runtime CLR type, as it does today under Akka's
  type-preserving JSON serializer. Dates ride as invariant round-trip strings,
  not Timestamp, which would silently normalise away DateTime.Kind and
  DateTimeOffset.Offset. Lists/maps recurse; anything outside the tagged set
  falls back to JSON and is documented as CLR-type-lossy.
- DebugViewSnapshot gets its own full-fidelity alarm/attribute messages rather
  than reusing sitestream's AlarmStateUpdate, which flattens values to display
  strings — right for a live stream, lossy for a snapshot the UI treats as
  authoritative. The encoder omits an AlarmStateChanged.Condition that already
  equals the record's derived default, so computed alarms round-trip exactly
  (record equality compares the nullable backing field, not the property).

Tests are reflection-driven so the coverage cannot drift: the round-trip theory
enumerates the mapper's own ToProto overloads, the envelope guards enumerate the
generated oneof descriptors, and a missing golden fails the build. 216 new tests
green (Communication 532 total, Commons 684 total, solution build 0/0).

Codegen is checked in under SiteCommandGrpc/ per the sitestream recipe; the
<Protobuf> ItemGroup stays commented out (an active one segfaults protoc in the
linux_arm64 Docker image). docker/regen-proto.sh now handles every proto in that
ItemGroup instead of just sitestream, and re-comments idempotently.
2026-07-22 20:04:23 -04:00

158 lines
6.0 KiB
C#

using System.Collections;
using System.Reflection;
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
/// <summary>
/// Structural deep-equality assertion for the site-command round-trip goldens.
/// </summary>
/// <remarks>
/// <para>
/// Record equality is NOT usable here. A C# record's generated <c>Equals</c>
/// compares members with <c>EqualityComparer&lt;T&gt;.Default</c>, which for
/// <c>IReadOnlyList&lt;T&gt;</c> / <c>IReadOnlyDictionary&lt;,&gt;</c> members
/// degrades to reference equality — so <c>Assert.Equal(dto, roundTripped)</c>
/// would fail on every collection-bearing message even when the mapper is
/// perfect, and (worse) would pass vacuously nowhere useful. This walker
/// compares by shape instead: dictionaries by key, sequences element-wise, and
/// everything else property-by-property down to leaf values.
/// </para>
/// <para>
/// The failure message carries the full property path, so a dropped field
/// reports as e.g. <c>DeployArtifactsCommand.ExternalSystems[0].TimeoutSeconds</c>
/// rather than an opaque "objects differ".
/// </para>
/// </remarks>
internal static class StructuralEquality
{
/// <summary>Asserts two object graphs are structurally identical.</summary>
/// <param name="expected">The original value.</param>
/// <param name="actual">The value that came back through the mapper.</param>
/// <param name="rootName">Name used as the root of the reported property path.</param>
public static void AssertDeepEqual(object? expected, object? actual, string rootName)
=> Compare(expected, actual, rootName);
private static void Compare(object? expected, object? actual, string path)
{
if (expected is null && actual is null)
{
return;
}
if (expected is null || actual is null)
{
Assert.Fail($"{path}: expected {Describe(expected)} but got {Describe(actual)}.");
return;
}
// JsonElement is the documented lossy escape hatch of LooseValueCodec; it
// has no useful Equals, so compare the canonical JSON text.
if (expected is JsonElement expectedJson && actual is JsonElement actualJson)
{
Assert.Equal(expectedJson.GetRawText(), actualJson.GetRawText());
return;
}
// Collections are compared by CONTENT, not by concrete container type.
// Members are declared IReadOnlyList<T>/IReadOnlyDictionary<,>, so the
// backing type (a collection-expression array here, a List<T> out of the
// mapper) is not part of the contract. Element and value types below are
// still compared strictly.
if (expected is not string && actual is not string)
{
if (expected is IDictionary expectedMap && actual is IDictionary actualMap)
{
CompareDictionaries(expectedMap, actualMap, path);
return;
}
if (expected is IEnumerable expectedSeq && actual is IEnumerable actualSeq)
{
CompareSequences(expectedSeq, actualSeq, path);
return;
}
}
var expectedType = expected.GetType();
var actualType = actual.GetType();
if (expectedType != actualType)
{
Assert.Fail(
$"{path}: type changed across the round trip — expected {expectedType.Name}, got {actualType.Name}.");
return;
}
if (IsLeaf(expectedType))
{
Assert.True(
Equals(expected, actual),
$"{path}: expected '{expected}' but got '{actual}'.");
return;
}
CompareProperties(expected, actual, expectedType, path);
}
private static void CompareDictionaries(IDictionary expected, IDictionary actual, string path)
{
Assert.True(
expected.Count == actual.Count,
$"{path}: entry count changed — expected {expected.Count}, got {actual.Count}.");
foreach (DictionaryEntry entry in expected)
{
Assert.True(
actual.Contains(entry.Key),
$"{path}: key '{entry.Key}' is missing after the round trip.");
Compare(entry.Value, actual[entry.Key], $"{path}['{entry.Key}']");
}
}
private static void CompareSequences(IEnumerable expected, IEnumerable actual, string path)
{
var expectedItems = expected.Cast<object?>().ToList();
var actualItems = actual.Cast<object?>().ToList();
Assert.True(
expectedItems.Count == actualItems.Count,
$"{path}: element count changed — expected {expectedItems.Count}, got {actualItems.Count}.");
for (var i = 0; i < expectedItems.Count; i++)
{
Compare(expectedItems[i], actualItems[i], $"{path}[{i}]");
}
}
private static void CompareProperties(object expected, object actual, Type type, string path)
{
var properties = type
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.ToList();
Assert.True(properties.Count > 0, $"{path}: {type.Name} exposes no readable properties to compare.");
foreach (var property in properties)
{
Compare(property.GetValue(expected), property.GetValue(actual), $"{path}.{property.Name}");
}
}
private static bool IsLeaf(Type type) =>
type.IsPrimitive
|| type.IsEnum
|| type == typeof(string)
|| type == typeof(decimal)
|| type == typeof(DateTime)
|| type == typeof(DateTimeOffset)
|| type == typeof(TimeSpan)
|| type == typeof(Guid)
// Value types with no collection members (e.g. TrackedOperationId) have a
// correct structural Equals of their own.
|| (type.IsValueType && !type.IsGenericType);
private static string Describe(object? value) => value is null ? "<null>" : $"{value.GetType().Name}('{value}')";
}