59b13d317b
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.
154 lines
5.6 KiB
C#
154 lines
5.6 KiB
C#
using System.Text.Json;
|
|
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.Communication.Tests;
|
|
|
|
/// <summary>
|
|
/// Goldens for <see cref="LooseValueCodec"/> — the type-tagged carrier for the
|
|
/// <c>object?</c> members of the site command plane (script parameters and
|
|
/// return values, attribute values, tag reads/writes).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The contract these tests pin down is that a boxed value keeps its RUNTIME
|
|
/// CLR TYPE across the wire, not merely its printed form. Today's Akka JSON
|
|
/// serializer preserves it, and an operator-facing tag value that silently
|
|
/// turns from <c>int</c> into <c>long</c> (or from <c>DateTime</c> into a
|
|
/// UTC-normalised copy) is a behaviour change, not a refactor.
|
|
/// </remarks>
|
|
public class LooseValueCodecTests
|
|
{
|
|
public static IEnumerable<object[]> TaggedScalars() =>
|
|
[
|
|
[true],
|
|
[false],
|
|
[42],
|
|
[-1],
|
|
[long.MaxValue],
|
|
[3.5d],
|
|
[1.5f],
|
|
["a string"],
|
|
[string.Empty],
|
|
[12.3456789m],
|
|
[new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Utc)],
|
|
[new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(-5))],
|
|
[Guid.Parse("11111111-2222-3333-4444-555555555555")],
|
|
[TimeSpan.FromMinutes(90)],
|
|
[new byte[] { 1, 2, 3 }]
|
|
];
|
|
|
|
[Theory]
|
|
[MemberData(nameof(TaggedScalars))]
|
|
public void TaggedValues_KeepTheirClrType(object value)
|
|
{
|
|
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(value));
|
|
|
|
Assert.NotNull(restored);
|
|
Assert.Equal(value.GetType(), restored.GetType());
|
|
StructuralEquality.AssertDeepEqual(value, restored, value.GetType().Name);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A local <see cref="DateTime"/> must come back local, and an offset
|
|
/// <see cref="DateTimeOffset"/> must come back with the same offset — the
|
|
/// reason these ride as round-trip strings instead of proto timestamps.
|
|
/// </summary>
|
|
[Fact]
|
|
public void DateValues_KeepKindAndOffset()
|
|
{
|
|
var local = new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Local);
|
|
var offset = new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(5.5));
|
|
|
|
var restoredLocal = Assert.IsType<DateTime>(LooseValueCodec.FromProto(LooseValueCodec.ToProto(local)));
|
|
var restoredOffset =
|
|
Assert.IsType<DateTimeOffset>(LooseValueCodec.FromProto(LooseValueCodec.ToProto(offset)));
|
|
|
|
Assert.Equal(DateTimeKind.Local, restoredLocal.Kind);
|
|
Assert.Equal(local, restoredLocal);
|
|
Assert.Equal(TimeSpan.FromHours(5.5), restoredOffset.Offset);
|
|
}
|
|
|
|
/// <summary>An unset carrier and an explicit null marker both decode to null.</summary>
|
|
[Fact]
|
|
public void NullIsCarriedTwoWays()
|
|
{
|
|
Assert.Null(LooseValueCodec.FromProto(null));
|
|
Assert.Null(LooseValueCodec.FromProto(LooseValueCodec.ToProto(null)));
|
|
Assert.Null(LooseValueCodec.ToProtoOrNull(null));
|
|
}
|
|
|
|
/// <summary>
|
|
/// A dictionary entry whose value is null stays distinct from an absent key —
|
|
/// the reason <c>LooseNull</c> exists at all.
|
|
/// </summary>
|
|
[Fact]
|
|
public void NullMapValue_SurvivesAsAPresentKey()
|
|
{
|
|
var original = new Dictionary<string, object?> { ["present"] = null };
|
|
|
|
var restored = LooseValueCodec.FromProtoMap(LooseValueCodec.ToProtoMap(original));
|
|
|
|
Assert.True(restored.ContainsKey("present"));
|
|
Assert.Null(restored["present"]);
|
|
}
|
|
|
|
/// <summary>A null dictionary stays null; an empty one stays empty.</summary>
|
|
[Fact]
|
|
public void NullMap_StaysDistinctFromEmptyMap()
|
|
{
|
|
Assert.Null(LooseValueCodec.ToProtoMapOrNull(null));
|
|
Assert.Null(LooseValueCodec.FromProtoMapOrNull(null));
|
|
|
|
var empty = LooseValueCodec.FromProtoMapOrNull(
|
|
LooseValueCodec.ToProtoMapOrNull(new Dictionary<string, object?>()));
|
|
|
|
Assert.NotNull(empty);
|
|
Assert.Empty(empty);
|
|
}
|
|
|
|
/// <summary>Nested lists and maps recurse, so element values keep their types too.</summary>
|
|
[Fact]
|
|
public void NestedCollections_KeepElementTypes()
|
|
{
|
|
object original = new List<object?>
|
|
{
|
|
1,
|
|
"two",
|
|
null,
|
|
new Dictionary<string, object?> { ["deep"] = 3.5d }
|
|
};
|
|
|
|
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(original));
|
|
|
|
StructuralEquality.AssertDeepEqual(original, restored, "list");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The documented widening: a typed collection keeps its ELEMENTS' types but
|
|
/// the container comes back as <c>List<object?></c>. Recorded here so
|
|
/// the behaviour is a decision rather than a surprise.
|
|
/// </summary>
|
|
[Fact]
|
|
public void TypedList_WidensToObjectList()
|
|
{
|
|
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(new List<int> { 1, 2, 3 }));
|
|
|
|
var list = Assert.IsType<List<object?>>(restored);
|
|
Assert.Equal([1, 2, 3], list.Cast<int>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// The documented lossy escape hatch: a value outside the tagged set keeps its
|
|
/// DATA but not its CLR identity — it decodes as a <see cref="JsonElement"/>.
|
|
/// Nothing on the command plane carries such a value today; the fallback
|
|
/// exists so an unexpected one cannot fault a command.
|
|
/// </summary>
|
|
[Fact]
|
|
public void UntaggedValue_FallsBackToJsonAndLosesItsClrType()
|
|
{
|
|
var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto((byte)7));
|
|
|
|
var element = Assert.IsType<JsonElement>(restored);
|
|
Assert.Equal("7", element.GetRawText());
|
|
}
|
|
}
|