using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment; using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming; using ZB.MOM.WW.ScadaBridge.Commons.Types; using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.Communication.Tests; /// /// Golden fixtures for the site-command round-trip suite: for every command, /// reply and nested type the mapper handles, at least one MAXIMALLY populated /// sample and one MINIMAL sample (every nullable null, every optional /// collection empty or absent). /// /// /// /// The two-sample rule is the point. A single "typical" sample proves only that /// the happy path survives; the pairs are what catch a dropped optional field or /// a null/empty collapse — the class of silent data loss the transport /// round-trip guard exposed in PLAN-05 T8, which per-field unit tests had all /// missed. /// /// /// drives this table by /// reflection and FAILS when the mapper gains a type that has no sample here, so /// the coverage cannot silently drift as commands are added. /// /// internal static class SiteCommandSamples { private static readonly DateTimeOffset T1 = new(2026, 7, 22, 13, 45, 12, 345, TimeSpan.Zero); private static readonly DateTimeOffset T2 = new(2026, 1, 2, 3, 4, 5, TimeSpan.Zero); private static readonly DateTime D1 = new(2026, 3, 4, 5, 6, 7, DateTimeKind.Utc); private static readonly DateTime D2 = new(2027, 3, 4, 5, 6, 7, DateTimeKind.Utc); private static readonly Guid G1 = Guid.Parse("11111111-2222-3333-4444-555555555555"); /// /// Every CLR type the mapper round-trips, mapped to its golden samples. /// Enums are excluded — they get their own exhaustive test. /// public static IReadOnlyDictionary All { get; } = Build(); private static Dictionary Build() { var samples = new Dictionary(); void Add(params T[] values) where T : notnull => samples[typeof(T)] = values.Cast().ToArray(); // ── Lifecycle commands ── Add(new RefreshDeploymentCommand( "dep-1", "Site1.Pump1", "hash-abc", "multi-role", T1, "http://central:5000", "tok-1")); Add(new EnableInstanceCommand("cmd-1", "Site1.Pump1", T1)); Add(new DisableInstanceCommand("cmd-2", "Site1.Pump1", T2)); Add(new DeleteInstanceCommand("cmd-3", "Site1.Pump1", T1)); Add(new DeploymentStateQueryRequest("corr-1", "Site1.Pump1", T1)); Add( // Full: every artifact collection populated. new DeployArtifactsCommand( "dep-2", [SharedScript(), new SharedScriptArtifact("s2", "return 2;", null, null)], [ExternalSystem()], [new DatabaseConnectionArtifact("db", "Server=x;", 3, TimeSpan.FromSeconds(7.5))], [new NotificationListArtifact("ops", ["a@x.com", "b@x.com"])], [DataConnection()], [Smtp()], T1), // Minimal: every collection NULL — must not come back as empty lists. new DeployArtifactsCommand("dep-3", null, null, null, null, null, null, T2), // Boundary: every collection present but EMPTY — must not come back null. new DeployArtifactsCommand("dep-4", [], [], [], [], [], [], T1)); Add(SharedScript(), new SharedScriptArtifact("bare", "", null, null)); Add(ExternalSystem(), new ExternalSystemArtifact("bare", "http://x", "None", null, null, 0)); Add(new DatabaseConnectionArtifact("db", "Server=x;", 3, TimeSpan.FromMilliseconds(1500)), new DatabaseConnectionArtifact("db2", "", 0, TimeSpan.Zero)); Add(new NotificationListArtifact("ops", ["a@x.com"]), new NotificationListArtifact("empty", [])); Add(DataConnection(), new DataConnectionArtifact("bare", "OpcUa", null, null, 0)); Add(Smtp(), new SmtpConfigurationArtifact("bare", "smtp", 25, "None", "f@x.com", null, null, null)); // ── Lifecycle replies ── Add(new DeploymentStatusResponse("dep-1", "Site1.Pump1", DeploymentStatus.Success, null, T1), new DeploymentStatusResponse("dep-1", "Site1.Pump1", DeploymentStatus.Failed, "boom", T2)); Add(new InstanceLifecycleResponse("cmd-1", "Site1.Pump1", true, null, T1), new InstanceLifecycleResponse("cmd-1", "Site1.Pump1", false, "nope", T2)); Add(new DeploymentStateQueryResponse("corr-1", "Site1.Pump1", true, "dep-1", "hash-abc", T1), new DeploymentStateQueryResponse("corr-1", "Site1.Pump1", false, null, null, T2)); Add(new ArtifactDeploymentResponse("dep-2", "site-a", true, null, T1), new ArtifactDeploymentResponse("dep-2", "site-a", false, "handler missing", T2)); // ── OPC UA commands ── Add(new BrowseNodeCommand("conn", "ns=2;s=Root", "cursor-1", "site-a"), new BrowseNodeCommand("conn", null, null, null)); Add(new SearchAddressSpaceCommand("conn", "pump", 4, 200, "site-a"), new SearchAddressSpaceCommand("conn", "", 0, 0, null)); Add(new ReadTagValuesCommand("conn", ["a", "b"]), new ReadTagValuesCommand("conn", [])); Add(new VerifyEndpointCommand("conn", "OpcUa", "{\"url\":\"opc.tcp://x\"}", "site-a"), new VerifyEndpointCommand("conn", "OpcUa", "{}", null)); Add(new TrustServerCertCommand("conn", "ZGVy", "AABB", "site-a"), new TrustServerCertCommand("conn", "ZGVy", "AABB", null)); Add(new ListServerCertsCommand("site-a"), new ListServerCertsCommand(null)); Add(new RemoveServerCertCommand("AABB", "site-a"), new RemoveServerCertCommand("AABB", null)); Add(new WriteTagRequest("corr-2", "conn", "ns=2;s=Speed", 42.5d, T1), new WriteTagRequest("corr-2", "conn", "ns=2;s=Speed", null, T2), new WriteTagRequest("corr-2", "conn", "ns=2;s=Flag", true, T1), new WriteTagRequest("corr-2", "conn", "ns=2;s=Name", "manual", T1)); // ── OPC UA replies ── Add(new BrowseNodeResult([Node(), NodeBare()], true, null, "cursor-2"), new BrowseNodeResult([], false, new BrowseFailure(BrowseFailureKind.Timeout, "timed out"), null)); Add(Node(), NodeBare()); Add(new BrowseFailure(BrowseFailureKind.ConnectionNotConnected, "not connected"), new BrowseFailure(BrowseFailureKind.ServerError, "")); Add(new SearchAddressSpaceResult([new AddressSpaceMatch(Node(), "/Root/Pump1")], true, null), new SearchAddressSpaceResult([], false, new BrowseFailure(BrowseFailureKind.NotBrowsable, "no"))); Add(new AddressSpaceMatch(Node(), "/Root/Pump1"), new AddressSpaceMatch(NodeBare(), "")); Add(new ReadTagValuesResult([Outcome(), OutcomeFailed()], null), new ReadTagValuesResult([], new ReadTagValuesFailure(ReadTagValuesFailureKind.ConnectionNotFound, "gone"))); Add(Outcome(), OutcomeFailed()); Add(new ReadTagValuesFailure(ReadTagValuesFailureKind.Timeout, "slow"), new ReadTagValuesFailure(ReadTagValuesFailureKind.ServerError, "")); Add(new VerifyEndpointResult(true, null, null, null), new VerifyEndpointResult(false, VerifyFailureKind.UntrustedCertificate, "untrusted", Cert()), new VerifyEndpointResult(false, VerifyFailureKind.Unreachable, "refused", null)); Add(Cert()); Add(new CertTrustResult(true, null, [Trusted(), TrustedRejected()]), new CertTrustResult(true, null, null), new CertTrustResult(false, "partial failure", [])); Add(Trusted(), TrustedRejected()); Add(new WriteTagResponse("corr-2", true, null, T1), new WriteTagResponse("corr-2", false, "denied", T2)); // ── Query commands ── Add(new EventLogQueryRequest( "corr-3", "site-a", T1, T2, "Lifecycle", "Warning", "Site1.Pump1", "restart", "cursor-3", 50, T1), new EventLogQueryRequest("corr-3", "site-a", null, null, null, null, null, null, null, 25, T2)); Add(new DebugSnapshotRequest("Site1.Pump1", "corr-4")); Add(new SubscribeDebugViewRequest("Site1.Pump1", "corr-5")); Add(new UnsubscribeDebugViewRequest("Site1.Pump1", "corr-6")); // ── Query replies ── Add(new EventLogQueryResponse("corr-3", "site-a", [Entry(), EntryBare()], "cursor-4", true, true, null, T1), new EventLogQueryResponse("corr-3", "site-a", [], null, false, false, "handler missing", T2)); Add(Entry(), EntryBare()); Add(new DebugViewSnapshot("Site1.Pump1", [AttrValue(), AttrValueNull()], [ComputedAlarm(), NativeAlarm()], T1), new DebugViewSnapshot("Site1.Pump1", [], [], T2, InstanceNotFound: true)); Add(AttrValue(), AttrValueNull(), AttrValueList()); Add(ComputedAlarm(), NativeAlarm()); Add(new AlarmConditionState(true, false, true, AlarmShelveState.TimedShelved, true, 900), new AlarmConditionState(false, true, null, AlarmShelveState.Unshelved, false, 0)); // ── Parked commands ── Add(new ParkedMessageQueryRequest("corr-7", "site-a", 2, 25, T1)); Add(new ParkedMessageRetryRequest("corr-8", "site-a", "msg-1", T1)); Add(new ParkedMessageDiscardRequest("corr-9", "site-a", "msg-1", T2)); Add(new RetryParkedOperation("corr-10", new TrackedOperationId(G1)), new RetryParkedOperation("corr-10", default)); Add(new DiscardParkedOperation("corr-11", new TrackedOperationId(G1)), new DiscardParkedOperation("corr-11", default)); // ── Parked replies ── Add(new ParkedMessageQueryResponse("corr-7", "site-a", [Parked(), ParkedBare()], 2, 1, 25, true, null, T1), new ParkedMessageQueryResponse("corr-7", "site-a", [], 0, 1, 25, false, "handler missing", T2)); Add(Parked(), ParkedBare()); Add(new ParkedMessageRetryResponse("corr-8", true), new ParkedMessageRetryResponse("corr-8", false, "not parked")); Add(new ParkedMessageDiscardResponse("corr-9", true), new ParkedMessageDiscardResponse("corr-9", false, "not parked")); Add(new ParkedOperationActionAck("corr-10", true), new ParkedOperationActionAck("corr-10", false, "handler missing")); // ── Route commands ── Add(new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", Parameters(), T1, G1), new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", null, T2), new RouteToCallRequest("corr-12", "Site1.Pump1", "Start", new Dictionary(), T1)); Add(new RouteToGetAttributesRequest("corr-13", "Site1.Pump1", ["Speed", "Flow"], T1, G1), new RouteToGetAttributesRequest("corr-13", "Site1.Pump1", [], T2)); Add(new RouteToSetAttributesRequest( "corr-14", "Site1.Pump1", new Dictionary { ["Speed"] = "10" }, T1, G1), new RouteToSetAttributesRequest("corr-14", "Site1.Pump1", new Dictionary(), T2)); Add(new RouteToWaitForAttributeRequest( "corr-15", "Site1.Pump1", "Speed", "10", TimeSpan.FromSeconds(30), T1, G1, true), new RouteToWaitForAttributeRequest( "corr-15", "Site1.Pump1", "Speed", null, TimeSpan.Zero, T2), // "" is a legitimate wait target and must NOT collapse to null. new RouteToWaitForAttributeRequest( "corr-15", "Site1.Pump1", "Speed", "", TimeSpan.FromMinutes(1), T1)); // ── Route replies ── Add(new RouteToCallResponse("corr-12", true, 17L, null, T1), new RouteToCallResponse("corr-12", false, null, "script faulted", T2)); Add(new RouteToGetAttributesResponse("corr-13", Parameters(), true, null, T1), new RouteToGetAttributesResponse("corr-13", new Dictionary(), false, "no instance", T2)); Add(new RouteToSetAttributesResponse("corr-14", true, null, T1), new RouteToSetAttributesResponse("corr-14", false, "locked", T2)); Add(new RouteToWaitForAttributeResponse("corr-15", true, 10, "Good", false, true, null, T1), new RouteToWaitForAttributeResponse("corr-15", false, null, null, true, true, null, T2)); // ── Failover ── Add(new TriggerSiteFailover("corr-16", "site-a")); Add(new SiteFailoverAck("corr-16", true, "akka.tcp://scadabridge@node-b:8081", null), new SiteFailoverAck("corr-16", false, null, "no standby available")); return samples; } private static SharedScriptArtifact SharedScript() => new("Calc", "return 1;", "{\"p\":\"int\"}", "{\"r\":\"int\"}"); private static ExternalSystemArtifact ExternalSystem() => new("Mes", "https://mes/api", "ApiKey", "{\"key\":\"x\"}", "[{\"name\":\"Post\"}]", 45); private static DataConnectionArtifact DataConnection() => new("Plc1", "OpcUa", "{\"url\":\"opc.tcp://a\"}", "{\"url\":\"opc.tcp://b\"}", 5); private static SmtpConfigurationArtifact Smtp() => new("Default", "smtp.host", 587, "Basic", "from@x.com", "user", "secret", "{\"tenant\":\"t\"}"); private static BrowseNode Node() => new("ns=2;s=Pump1.Speed", "Speed", BrowseNodeClass.Variable, false, "Double", -1, true); private static BrowseNode NodeBare() => new("ns=2;s=Root", "Root", BrowseNodeClass.Object, true); private static TagReadOutcome Outcome() => new("ns=2;s=Speed", true, 12.5d, "Good", T1, null); private static TagReadOutcome OutcomeFailed() => new("ns=2;s=Bad", false, null, "Bad", T2, "no such node"); private static ServerCertInfo Cert() => new("AABB", "CN=server", "CN=issuer", D1, D2, "ZGVy"); private static TrustedCertInfo Trusted() => new("AABB", "CN=server", "CN=issuer", D1, D2, false); private static TrustedCertInfo TrustedRejected() => new("CCDD", "CN=other", "CN=issuer", D1, D2, true); private static EventLogEntry Entry() => new(G1.ToString("D"), T1, "Lifecycle", "Warning", "Site1.Pump1", "InstanceActor", "restarted", "{\"n\":1}"); private static EventLogEntry EntryBare() => new(Guid.Empty.ToString("D"), T2, "System", "Info", null, "Host", "started", null); private static ParkedMessageEntry Parked() => new("msg-1", "Mes", "Post", "500 from server", 7, T1, T2, 10, StoreAndForwardCategory.CachedDbWrite, "Site1.Pump1"); private static ParkedMessageEntry ParkedBare() => new("msg-2", "Mes", "Post", "", 0, T2, T2); private static AttributeValueChanged AttrValue() => new("Site1.Pump1", "Pump1.Speed", "Speed", 12.5d, "Good", T1); private static AttributeValueChanged AttrValueNull() => new("Site1.Pump1", "Pump1.Speed", "Speed", null, "Bad", T2); private static AttributeValueChanged AttrValueList() => new("Site1.Pump1", "Pump1.Trend", "Trend", new List { 1, 2.5d, "x", null }, "Good", T1); /// A computed alarm: Condition left implicit, which is the common case. private static AlarmStateChanged ComputedAlarm() => new("Site1.Pump1", "HighSpeed", AlarmState.Active, 700, T1) { Level = AlarmLevel.HighHigh, Message = "Speed critically high" }; /// A mirrored native alarm: every native enrichment field populated, Condition explicit. private static AlarmStateChanged NativeAlarm() => new("Site1.Pump1", "Tank01.Level.HiHi", AlarmState.Active, 850, T2) { Level = AlarmLevel.None, Message = "Level high", Kind = AlarmKind.NativeOpcUa, Condition = new AlarmConditionState(true, false, false, AlarmShelveState.OneShotShelved, true, 850), SourceReference = "Tank01.Level.HiHi", AlarmTypeName = "AnalogLimitAlarm.HiHi", Category = "Process", OperatorUser = "multi-role", OperatorComment = "ack'd at panel", OriginalRaiseTime = T1, CurrentValue = "91.2", LimitValue = "90.0", NativeSourceCanonicalName = "Tank01.TankAlarms", IsConfiguredPlaceholder = true }; private static Dictionary Parameters() => new() { ["count"] = 3, ["ratio"] = 1.25d, ["name"] = "batch-1", ["enabled"] = true, ["missing"] = null, ["when"] = T1, ["id"] = G1, ["items"] = new List { 1L, "two", null }, ["nested"] = new Dictionary { ["inner"] = 5f } }; }