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.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); } // ============ 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!); } // ============ 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) => new( Name: name, FolderName: null, BaseTemplateName: null, Description: null, Attributes: Array.Empty(), 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()); }