using System.Text.Json; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; using ZB.MOM.WW.ScadaBridge.Transport.Import; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; namespace ZB.MOM.WW.ScadaBridge.Transport.Tests.Import; /// /// Tests for : the structured per-line Myers code diff /// embedded in FieldDiffJson (T20) and the M8 Site / DataConnection / /// Instance compares. The diff records are private, so assertions parse the /// serialized . /// public sealed class ArtifactDiffTests { private readonly ArtifactDiff _diff = new(); // ---- JSON inspection helpers ---- private static JsonElement Changes(ImportPreviewItem item) { Assert.NotNull(item.FieldDiffJson); using var doc = JsonDocument.Parse(item.FieldDiffJson!); // Clone so the element survives the using-scope disposal. return doc.RootElement.GetProperty("changes").Clone(); } private static JsonElement ChangeFor(ImportPreviewItem item, string field) { foreach (var c in Changes(item).EnumerateArray()) { if (c.GetProperty("field").GetString() == field) { return c.Clone(); } } Assert.Fail($"No change for field '{field}'. JSON: {item.FieldDiffJson}"); return default; // unreachable } private static IReadOnlyList<(string Op, string Text)> Hunks(JsonElement change) { var lineDiff = change.GetProperty("lineDiff"); var hunks = new List<(string, string)>(); foreach (var h in lineDiff.GetProperty("hunks").EnumerateArray()) { hunks.Add((h.GetProperty("op").GetString()!, h.GetProperty("text").GetString()!)); } return hunks; } // ============ T20: structured per-line code diff ============ [Fact] public void SharedScript_ModifiedCode_EmitsStructuredLineDiff() { var existing = new SharedScript("calc", "var a = 1;\nvar b = 2;\nreturn a + b;"); var incoming = new SharedScriptDto( Name: "calc", Code: "var a = 1;\nvar b = 20;\nreturn a + b;", ParameterDefinitions: existing.ParameterDefinitions, ReturnDefinition: existing.ReturnDefinition); var item = _diff.CompareSharedScript(incoming, existing); Assert.Equal(ConflictKind.Modified, item.Kind); var change = ChangeFor(item, "Code"); // Compact summary retained for fallback. Assert.Equal("<3 lines>", change.GetProperty("oldValue").GetString()); Assert.Equal("<3 lines>", change.GetProperty("newValue").GetString()); var lineDiff = change.GetProperty("lineDiff"); Assert.False(lineDiff.GetProperty("truncated").GetBoolean()); Assert.Equal(1, lineDiff.GetProperty("addedCount").GetInt32()); Assert.Equal(1, lineDiff.GetProperty("removedCount").GetInt32()); var hunks = Hunks(change); // The changed middle line shows as a remove + add; unchanged lines are context. Assert.Contains(("remove", "var b = 2;"), hunks); Assert.Contains(("add", "var b = 20;"), hunks); Assert.Contains(("context", "var a = 1;"), hunks); Assert.Contains(("context", "return a + b;"), hunks); // Not a ""-only marker any more. Assert.DoesNotContain(hunks, h => h.Text.Contains("lines>")); } [Fact] public void SharedScript_IdenticalCode_NoChange() { var existing = new SharedScript("calc", "return 42;"); var incoming = new SharedScriptDto("calc", "return 42;", existing.ParameterDefinitions, existing.ReturnDefinition); var item = _diff.CompareSharedScript(incoming, existing); Assert.Equal(ConflictKind.Identical, item.Kind); Assert.Null(item.FieldDiffJson); } [Fact] public void ApiMethod_ModifiedScript_EmitsStructuredLineDiff() { var existing = new ApiMethod("doThing", "log(\"a\");") { TimeoutSeconds = 30 }; var incoming = new ApiMethodDto( Name: "doThing", Script: "log(\"a\");\nlog(\"b\");", ParameterDefinitions: existing.ParameterDefinitions, ReturnDefinition: existing.ReturnDefinition, TimeoutSeconds: 30); var item = _diff.CompareApiMethod(incoming, existing); Assert.Equal(ConflictKind.Modified, item.Kind); var change = ChangeFor(item, "Script"); var lineDiff = change.GetProperty("lineDiff"); Assert.Equal(1, lineDiff.GetProperty("addedCount").GetInt32()); Assert.Equal(0, lineDiff.GetProperty("removedCount").GetInt32()); var hunks = Hunks(change); Assert.Contains(("context", "log(\"a\");"), hunks); Assert.Contains(("add", "log(\"b\");"), hunks); } [Fact] public void TemplateScript_ModifiedCode_EmitsStructuredLineDiff() { var existing = MakeTemplate("T1"); existing.Scripts.Add(new TemplateScript("onScan", "old1\nold2")); var incoming = MakeTemplateDto("T1", scripts: [ new TemplateScriptDto("onScan", "old1\nnew2", null, null, null, null, false, null), ]); var item = _diff.CompareTemplate(incoming, existing); Assert.Equal(ConflictKind.Modified, item.Kind); var change = ChangeFor(item, "Scripts.onScan"); var hunks = Hunks(change); Assert.Contains(("context", "old1"), hunks); Assert.Contains(("remove", "old2"), hunks); Assert.Contains(("add", "new2"), hunks); } // ============ #05-T23: placeholder-identity resolution via name maps ============ [Fact] public void CompareTemplate_MatchingFolderBaseAndComposition_IsIdentical() { // A structured template (FolderId, ParentTemplateId, one composition) that // the incoming bundle matches by NAME must classify Identical. Before T23, // FolderNameOf/BaseTemplateNameOf/CompositionTargetNameOf returned "" // placeholders that never equalled the incoming names, so every re-import // of an unchanged structured template spuriously read Modified. var existing = MakeTemplate("Pump"); existing.FolderId = 5; existing.ParentTemplateId = 7; existing.Compositions.Add(new TemplateComposition("Mod1") { ComposedTemplateId = 9 }); var folderNameById = new Dictionary { [5] = "Machines" }; var templateNameById = new Dictionary { [7] = "BasePump", [9] = "FeatureX" }; var incoming = MakeTemplateDto("Pump") with { FolderName = "Machines", BaseTemplateName = "BasePump", Compositions = new[] { new TemplateCompositionDto("Mod1", "FeatureX") }, }; var item = _diff.CompareTemplate(incoming, existing, folderNameById, templateNameById); Assert.Equal(ConflictKind.Identical, item.Kind); } [Fact] public void CompareTemplate_DivergentBaseTemplateName_IsModified() { // Sanity: the maps must not mask a REAL change. Existing base = "BasePump", // incoming names a different base → Modified. var existing = MakeTemplate("Pump"); existing.ParentTemplateId = 7; var templateNameById = new Dictionary { [7] = "BasePump" }; var incoming = MakeTemplateDto("Pump") with { BaseTemplateName = "DifferentBase" }; var item = _diff.CompareTemplate(incoming, existing, folderNameById: null, templateNameById: templateNameById); Assert.Equal(ConflictKind.Modified, item.Kind); } // ============ #05-T6: single-source child equality — diff sees the full field set ============ [Fact] public void CompareTemplate_AttributeElementDataTypeChange_IsModified() { var existing = MakeTemplate("T1"); existing.Attributes.Add(new TemplateAttribute("L") { DataType = DataType.List, ElementDataType = DataType.Int32, Value = "[1,2]", }); var incoming = MakeTemplateDto("T1", attributes: [ new TemplateAttributeDto("L", "[1,2]", DataType.List, false, null, null, DataType.String), ]); var item = _diff.CompareTemplate(incoming, existing); Assert.Equal(ConflictKind.Modified, item.Kind); ChangeFor(item, "Attributes.L"); // ElementDataType diverged — previously missed. } [Fact] public void CompareTemplate_LockedInDerivedChange_IsModified() { var existing = MakeTemplate("T1"); existing.Attributes.Add(new TemplateAttribute("L") { DataType = DataType.Int32, Value = "1", LockedInDerived = false, }); var incoming = MakeTemplateDto("T1", attributes: [ new TemplateAttributeDto("L", "1", DataType.Int32, false, null, null, null, LockedInDerived: true), ]); var item = _diff.CompareTemplate(incoming, existing); Assert.Equal(ConflictKind.Modified, item.Kind); ChangeFor(item, "Attributes.L"); // LockedInDerived diverged — previously missed. } [Fact] public void CompareTemplate_ScriptExecutionTimeoutChange_IsModified() { var existing = MakeTemplate("T1"); existing.Scripts.Add(new TemplateScript("S", "return 1;") { ExecutionTimeoutSeconds = 30 }); var incoming = MakeTemplateDto("T1", scripts: [ // identical code; only ExecutionTimeoutSeconds differs. new TemplateScriptDto("S", "return 1;", null, null, null, null, false, null, ExecutionTimeoutSeconds: 60), ]); var item = _diff.CompareTemplate(incoming, existing); Assert.Equal(ConflictKind.Modified, item.Kind); ChangeFor(item, "Scripts.S"); // ExecutionTimeoutSeconds diverged — previously missed. } [Fact] public void CompareTemplate_AlarmOnTriggerScriptChange_IsModified() { var existing = MakeTemplate("T1"); existing.Scripts.Add(new TemplateScript("Run", "return 1;") { Id = 1 }); existing.Alarms.Add(new TemplateAlarm("A") { TriggerType = AlarmTriggerType.RangeViolation, OnTriggerScriptId = 1, // resolves to "Run" }); var incoming = MakeTemplateDto("T1", scripts: [new TemplateScriptDto("Run", "return 1;", null, null, null, null, false, null)], alarms: [ // same alarm fields but no on-trigger script (was "Run"). new TemplateAlarmDto("A", null, 0, AlarmTriggerType.RangeViolation, null, false, OnTriggerScriptName: null), ]); var item = _diff.CompareTemplate(incoming, existing); Assert.Equal(ConflictKind.Modified, item.Kind); ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed. } // ============ M8: CompareSite ============ [Fact] public void CompareSite_ExistingNull_New() { var dto = new SiteDto("site-a", "Site A", null, null, null, null, null); var item = _diff.CompareSite(dto, existing: null); Assert.Equal(ConflictKind.New, item.Kind); Assert.Equal("Site", item.EntityType); Assert.Equal("site-a", item.Name); Assert.Null(item.FieldDiffJson); } [Fact] public void CompareSite_FieldDiffers_Modified() { var existing = new Site("Site A", "site-a") { Description = "old", NodeAAddress = "10.0.0.1" }; var dto = new SiteDto("site-a", "Site A", "new", "10.0.0.1", null, null, null); var item = _diff.CompareSite(dto, existing); Assert.Equal(ConflictKind.Modified, item.Kind); var change = ChangeFor(item, "Description"); Assert.Equal("old", change.GetProperty("oldValue").GetString()); Assert.Equal("new", change.GetProperty("newValue").GetString()); } [Fact] public void CompareSite_NoChanges_Identical() { var existing = new Site("Site A", "site-a") { Description = "d", NodeAAddress = "a", NodeBAddress = "b", GrpcNodeAAddress = "ga", GrpcNodeBAddress = "gb", }; var dto = new SiteDto("site-a", "Site A", "d", "a", "b", "ga", "gb"); var item = _diff.CompareSite(dto, existing); Assert.Equal(ConflictKind.Identical, item.Kind); Assert.Null(item.FieldDiffJson); } // ============ M8: CompareDataConnection ============ [Fact] public void CompareDataConnection_ExistingNull_New() { var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3, null); var item = _diff.CompareDataConnection(dto, existing: null); Assert.Equal(ConflictKind.New, item.Kind); Assert.Equal("DataConnection", item.EntityType); Assert.Equal("opc1", item.Name); } [Fact] public void CompareDataConnection_ProtocolDiffers_Modified() { var existing = new DataConnection("opc1", "OpcUa", siteId: 1) { FailoverRetryCount = 3 }; var dto = new DataConnectionDto("site-a", "opc1", "MxAccess", 3, null); var item = _diff.CompareDataConnection(dto, existing); Assert.Equal(ConflictKind.Modified, item.Kind); var change = ChangeFor(item, "Protocol"); Assert.Equal("OpcUa", change.GetProperty("oldValue").GetString()); Assert.Equal("MxAccess", change.GetProperty("newValue").GetString()); } [Fact] public void CompareDataConnection_SecretValueChangesButPresencePreserved_NoSecretEcho() { // Both sides HAVE a primary configuration secret; only the value differs. // Presence-only comparison => no Secrets.* change, and the value is never echoed. var existing = new DataConnection("opc1", "OpcUa", siteId: 1) { FailoverRetryCount = 3, PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://OLD:4840\"}", }; var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3, new SecretsBlock(new Dictionary { ["PrimaryConfiguration"] = "{\"endpoint\":\"opc.tcp://NEW:4840\"}", })); var item = _diff.CompareDataConnection(dto, existing); Assert.Equal(ConflictKind.Identical, item.Kind); Assert.Null(item.FieldDiffJson); // The secret value must never appear anywhere in the diff output. Assert.DoesNotContain("4840", item.FieldDiffJson ?? string.Empty); } [Fact] public void CompareDataConnection_SecretPresenceFlips_ShowsPresentMarkerOnly() { // Existing has a primary config; incoming carries no secret => presence change. var existing = new DataConnection("opc1", "OpcUa", siteId: 1) { FailoverRetryCount = 3, PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://h:4840\"}", }; var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3, Secrets: null); var item = _diff.CompareDataConnection(dto, existing); Assert.Equal(ConflictKind.Modified, item.Kind); var change = ChangeFor(item, "Secrets.PrimaryConfiguration"); Assert.Equal("", change.GetProperty("oldValue").GetString()); // newValue is null and omitted from JSON by the WhenWritingNull policy. Assert.False(change.TryGetProperty("newValue", out _)); Assert.DoesNotContain("4840", item.FieldDiffJson!); } // ============ SMS: CompareSmsConfiguration (S10b) ============ private static SmsConfiguration ExistingSms() => new("AC1", "+15550000000") { Id = 1, AuthToken = "stored-token" }; private static SmsConfigDto SmsDto( string from = "+15550000000", string? msgSid = null, bool withSecret = true) => new("AC1", from, msgSid, ApiBaseUrl: null, ConnectionTimeoutSeconds: 30, MaxRetries: 10, RetryDelay: TimeSpan.FromMinutes(1), Secrets: withSecret ? new SecretsBlock(new Dictionary { ["AuthToken"] = "wire-token" }) : null); [Fact] public void CompareSmsConfiguration_ExistingNull_New() { var item = _diff.CompareSmsConfiguration(SmsDto(), existing: null); Assert.Equal(ConflictKind.New, item.Kind); Assert.Equal("SmsConfiguration", item.EntityType); Assert.Equal("AC1", item.Name); } [Fact] public void CompareSmsConfiguration_AllFieldsAndPresenceMatch_Identical() { var item = _diff.CompareSmsConfiguration(SmsDto(), ExistingSms()); Assert.Equal(ConflictKind.Identical, item.Kind); Assert.Null(item.FieldDiffJson); } [Fact] public void CompareSmsConfiguration_FromNumberDiffers_Modified() { var item = _diff.CompareSmsConfiguration(SmsDto(from: "+15559999999"), ExistingSms()); Assert.Equal(ConflictKind.Modified, item.Kind); var change = ChangeFor(item, "FromNumber"); Assert.Equal("+15550000000", change.GetProperty("oldValue").GetString()); Assert.Equal("+15559999999", change.GetProperty("newValue").GetString()); } [Fact] public void CompareSmsConfiguration_AuthTokenValueChangesButPresencePreserved_NoSecretEcho() { // Both sides carry a token; only the value differs. Presence-only comparison => // no Secrets.AuthToken change, and neither token value is ever echoed. var item = _diff.CompareSmsConfiguration(SmsDto(withSecret: true), ExistingSms()); Assert.Equal(ConflictKind.Identical, item.Kind); var json = item.FieldDiffJson ?? string.Empty; Assert.DoesNotContain("stored-token", json); Assert.DoesNotContain("wire-token", json); } [Fact] public void CompareSmsConfiguration_AuthTokenPresenceFlips_ShowsPresentMarkerOnly() { // Existing has a token; incoming carries no secret => a presence change that // surfaces only the "" marker, never the token value. var item = _diff.CompareSmsConfiguration(SmsDto(withSecret: false), ExistingSms()); Assert.Equal(ConflictKind.Modified, item.Kind); var change = ChangeFor(item, "Secrets.AuthToken"); Assert.Equal("", change.GetProperty("oldValue").GetString()); Assert.False(change.TryGetProperty("newValue", out _)); Assert.DoesNotContain("stored-token", item.FieldDiffJson!); } // ============ M8: CompareInstance ============ [Fact] public void CompareInstance_ExistingNull_New() { var dto = MakeInstanceDto("inst-1"); var item = _diff.CompareInstance(dto, existing: null); Assert.Equal(ConflictKind.New, item.Kind); Assert.Equal("Instance", item.EntityType); Assert.Equal("inst-1", item.Name); } [Fact] public void CompareInstance_StateDiffers_Modified() { var existing = new Instance("inst-1") { State = InstanceState.Disabled }; var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled); var item = _diff.CompareInstance(dto, existing); Assert.Equal(ConflictKind.Modified, item.Kind); var change = ChangeFor(item, "State"); Assert.Equal("Disabled", change.GetProperty("oldValue").GetString()); Assert.Equal("Enabled", change.GetProperty("newValue").GetString()); } [Fact] public void CompareInstance_NoChanges_Identical() { var existing = new Instance("inst-1") { State = InstanceState.Enabled }; var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled); var item = _diff.CompareInstance(dto, existing); Assert.Equal(ConflictKind.Identical, item.Kind); Assert.Null(item.FieldDiffJson); } [Fact] public void CompareInstance_TemplateAndSiteNamesDiffer_Modified() { var existing = new Instance("inst-1") { State = InstanceState.Enabled }; var dto = MakeInstanceDto("inst-1", template: "TmplB", site: "site-b", state: InstanceState.Enabled); var item = _diff.CompareInstance(dto, existing, existingTemplateName: "TmplA", existingSiteIdentifier: "site-a"); Assert.Equal(ConflictKind.Modified, item.Kind); Assert.Equal("TmplA", ChangeFor(item, "TemplateName").GetProperty("oldValue").GetString()); Assert.Equal("TmplB", ChangeFor(item, "TemplateName").GetProperty("newValue").GetString()); Assert.Equal("site-a", ChangeFor(item, "SiteIdentifier").GetProperty("oldValue").GetString()); Assert.Equal("site-b", ChangeFor(item, "SiteIdentifier").GetProperty("newValue").GetString()); } [Fact] public void CompareInstance_ChildOverridesAddedRemovedModified() { var existing = new Instance("inst-1") { State = InstanceState.Enabled }; // Will be REMOVED (present existing, absent incoming). existing.AttributeOverrides.Add(new InstanceAttributeOverride("Setpoint") { OverrideValue = "10" }); // Will be MODIFIED (present both, value differs). existing.AttributeOverrides.Add(new InstanceAttributeOverride("Scale") { OverrideValue = "1.0" }); var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled, attributeOverrides: [ new InstanceAttributeOverrideDto("Scale", "2.0", null), // modified new InstanceAttributeOverrideDto("Offset", "5", null), // added ]); var item = _diff.CompareInstance(dto, existing); Assert.Equal(ConflictKind.Modified, item.Kind); var removed = ChangeFor(item, "AttributeOverrides.Setpoint"); Assert.Equal("", removed.GetProperty("oldValue").GetString()); Assert.False(removed.TryGetProperty("newValue", out _)); var added = ChangeFor(item, "AttributeOverrides.Offset"); Assert.False(added.TryGetProperty("oldValue", out _)); Assert.Equal("", added.GetProperty("newValue").GetString()); var modified = ChangeFor(item, "AttributeOverrides.Scale"); Assert.Equal("", modified.GetProperty("oldValue").GetString()); Assert.Equal("", modified.GetProperty("newValue").GetString()); } // ---- builders ---- private static Commons.Entities.Templates.Template MakeTemplate(string name) => new(name); private static TemplateDto MakeTemplateDto( string name, IReadOnlyList? scripts = null, IReadOnlyList? attributes = null, IReadOnlyList? alarms = null) => new( Name: name, FolderName: null, BaseTemplateName: null, Description: null, Attributes: attributes ?? Array.Empty(), Alarms: alarms ?? Array.Empty(), Scripts: scripts ?? Array.Empty(), Compositions: Array.Empty()); private static InstanceDto MakeInstanceDto( string uniqueName, string template = "Tmpl", string site = "site-a", InstanceState state = InstanceState.Enabled, IReadOnlyList? attributeOverrides = null) => new( UniqueName: uniqueName, TemplateName: template, SiteIdentifier: site, AreaName: null, State: state, AttributeOverrides: attributeOverrides ?? Array.Empty(), AlarmOverrides: Array.Empty(), NativeAlarmSourceOverrides: Array.Empty(), ConnectionBindings: Array.Empty()); }