diff --git a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs index 959ece09..83156e36 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Transport/Import/ArtifactDiff.cs @@ -2,8 +2,10 @@ using System.Text.Json; using System.Text.Json.Serialization; using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; 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.Transport; using ZB.MOM.WW.ScadaBridge.Transport.Serialization; @@ -17,11 +19,15 @@ namespace ZB.MOM.WW.ScadaBridge.Transport.Import; /// diff in JSON. /// /// "Coarse" means: each persistent field is compared as a value; differing -/// fields appear in changes with old/new values (or hashes for large -/// blobs like script code). Per-line / Myers-style diff is explicitly out of -/// scope for v1 — the design plan defers it to a follow-up task. Script -/// bodies record only a line-count delta to give the operator a sense of the -/// change without paying the diff cost up front. +/// non-code fields appear in changes with old/new values. Code / large- +/// text fields (script bodies, API-method scripts) instead carry a structured +/// per-line Myers diff (T20): the / +/// keep a compact <N lines> +/// summary for fallback rendering, and a +/// payload carries the hunk lines (+/-/context) plus add/remove totals and a +/// truncation flag. The diff is size-capped via 's +/// maxLines cap so stays +/// bounded. /// /// /// Entity versions are not yet tracked on the POCOs, so the @@ -287,6 +293,153 @@ public sealed class ArtifactDiff return BuildItem("TemplateFolder", incoming.Name, changes); } + // ---- Site / Connection / Instance (M8) ---- + + /// + /// Compares an incoming site against the existing site in the database. + /// Identity is the SiteIdentifier; the diff is coarse over the + /// display name, description, and the four cluster/gRPC node addresses. + /// + /// The incoming site from the bundle. + /// The existing site in the database, or null if new. + /// An import preview item describing the conflict type and differences. + public ImportPreviewItem CompareSite(SiteDto incoming, Site? existing) + { + ArgumentNullException.ThrowIfNull(incoming); + if (existing is null) return New("Site", incoming.SiteIdentifier); + + var changes = new List(); + AddIfDifferent(changes, "Name", existing.Name, incoming.Name); + AddIfDifferent(changes, "Description", existing.Description, incoming.Description); + AddIfDifferent(changes, "NodeAAddress", existing.NodeAAddress, incoming.NodeAAddress); + AddIfDifferent(changes, "NodeBAddress", existing.NodeBAddress, incoming.NodeBAddress); + AddIfDifferent(changes, "GrpcNodeAAddress", existing.GrpcNodeAAddress, incoming.GrpcNodeAAddress); + AddIfDifferent(changes, "GrpcNodeBAddress", existing.GrpcNodeBAddress, incoming.GrpcNodeBAddress); + + return BuildItem("Site", incoming.SiteIdentifier, changes); + } + + /// + /// Compares an incoming site-scoped data connection (the Sites.DataConnection + /// entity, NOT the External-System database connection) against the existing one. + /// The Primary/Backup protocol configuration lives in the DTO's + /// , so it is compared presence-only — the diff + /// never echoes the configuration value, mirroring the external-system / DB-connection + /// secret handling. + /// + /// The incoming data connection from the bundle. + /// The existing data connection in the database, or null if new. + /// An import preview item describing the conflict type and differences. + public ImportPreviewItem CompareDataConnection(DataConnectionDto incoming, DataConnection? existing) + { + ArgumentNullException.ThrowIfNull(incoming); + if (existing is null) return New("DataConnection", incoming.Name); + + var changes = new List(); + AddIfDifferent(changes, "Protocol", existing.Protocol, incoming.Protocol); + AddIfDifferent(changes, "FailoverRetryCount", existing.FailoverRetryCount, incoming.FailoverRetryCount); + + // Protocol config rides in Secrets (PrimaryConfiguration / BackupConfiguration). + // Presence-only comparison — never echo the config value. + AddSecretPresenceChange(changes, "Secrets.PrimaryConfiguration", + !string.IsNullOrEmpty(existing.PrimaryConfiguration), + HasSecretKey(incoming.Secrets, "PrimaryConfiguration")); + AddSecretPresenceChange(changes, "Secrets.BackupConfiguration", + !string.IsNullOrEmpty(existing.BackupConfiguration), + HasSecretKey(incoming.Secrets, "BackupConfiguration")); + + return BuildItem("DataConnection", incoming.Name, changes); + } + + /// + /// Compares an incoming deployable instance against the existing one. Identity + /// is the UniqueName; the diff is coarse over the template/site/area/state + /// scalar fields plus a name-keyed child diff over the four override/binding + /// collections. + /// + /// The caller is responsible for passing a hydrated + /// (its AttributeOverrides, AlarmOverrides, NativeAlarmSourceOverrides, + /// and ConnectionBindings navigation collections eagerly loaded). A + /// non-hydrated entity reads as having no children, which would surface every + /// incoming child as an addition. + /// + /// + /// The incoming instance from the bundle. + /// The existing (hydrated) instance in the database, or null if new. + /// The existing instance's template name (the entity carries only a FK), or null if unknown. + /// The existing instance's site identifier (the entity carries only a FK), or null if unknown. + /// The existing instance's area name (the entity carries only a FK), or null if unknown / unset. + /// An import preview item describing the conflict type and differences. + public ImportPreviewItem CompareInstance( + InstanceDto incoming, + Instance? existing, + string? existingTemplateName = null, + string? existingSiteIdentifier = null, + string? existingAreaName = null) + { + ArgumentNullException.ThrowIfNull(incoming); + if (existing is null) return New("Instance", incoming.UniqueName); + + var changes = new List(); + // The entity stores template/site/area as numeric FKs that can't be compared + // cross-environment; the caller resolves them to names. When a name wasn't + // supplied we skip that scalar rather than emit a spurious "" diff. + if (existingTemplateName is not null) + { + AddIfDifferent(changes, "TemplateName", existingTemplateName, incoming.TemplateName); + } + if (existingSiteIdentifier is not null) + { + AddIfDifferent(changes, "SiteIdentifier", existingSiteIdentifier, incoming.SiteIdentifier); + } + AddIfDifferent(changes, "AreaName", existingAreaName, incoming.AreaName); + AddIfDifferent(changes, "State", existing.State.ToString(), incoming.State.ToString()); + + DiffChildren( + existing.AttributeOverrides, + incoming.AttributeOverrides, + e => e.AttributeName, + i => i.AttributeName, + (e, i) => e.OverrideValue == i.OverrideValue && e.ElementDataType == i.ElementDataType, + "AttributeOverrides", + changes); + + DiffChildren( + existing.AlarmOverrides, + incoming.AlarmOverrides, + e => e.AlarmCanonicalName, + i => i.AlarmCanonicalName, + (e, i) => e.TriggerConfigurationOverride == i.TriggerConfigurationOverride + && e.PriorityLevelOverride == i.PriorityLevelOverride, + "AlarmOverrides", + changes); + + DiffChildren( + existing.NativeAlarmSourceOverrides, + incoming.NativeAlarmSourceOverrides, + e => e.SourceCanonicalName, + i => i.SourceCanonicalName, + (e, i) => e.ConnectionNameOverride == i.ConnectionNameOverride + && e.SourceReferenceOverride == i.SourceReferenceOverride + && e.ConditionFilterOverride == i.ConditionFilterOverride, + "NativeAlarmSourceOverrides", + changes); + + DiffChildren( + existing.ConnectionBindings, + incoming.ConnectionBindings, + e => e.AttributeName, + i => i.AttributeName, + // ConnectionName resolves to a FK on the entity (DataConnectionId) that + // can't be compared cross-environment, so the binding diff compares only + // the per-attribute DataSourceReference override. + (e, i) => e.DataSourceReferenceOverride == i.DataSourceReferenceOverride, + "ConnectionBindings", + changes); + + return BuildItem("Instance", incoming.UniqueName, changes); + } + // ---- Helpers ---- private static ImportPreviewItem New(string entityType, string name) => @@ -315,15 +468,68 @@ public sealed class ArtifactDiff private static void AddCodeChangeIfDifferent(List changes, string field, string? existing, string? incoming) { - // Script bodies can be large — record a line-count delta + change marker - // instead of inlining the full text so the diff JSON stays compact. + // Code/large-text fields: when they differ, emit a real per-line Myers diff + // (T20). The OldValue/NewValue keep a compact "" summary as a + // fallback for renderers that don't consume the structured payload; the + // LineDiff carries the +/- hunk lines. Identical code emits no change. var sameNullness = existing is null == incoming is null; var bothPresentAndEqual = sameNullness && (existing is null || string.Equals(existing, incoming, StringComparison.Ordinal)); if (bothPresentAndEqual) return; + changes.Add(BuildCodeFieldChange(field, existing, incoming)); + } + + /// + /// Builds a for a code/large-text field carrying both + /// the compact line-count summary and the structured per-line Myers diff. Only + /// ever called for the named code fields (TemplateScript.Code, SharedScript.Code, + /// ApiMethod.Script) — never for secret-bearing fields — so the verbatim line + /// text emitted here is never sensitive. + /// + private static FieldChange BuildCodeFieldChange(string field, string? existing, string? incoming) + { var oldLines = existing?.Split('\n').Length ?? 0; var newLines = incoming?.Split('\n').Length ?? 0; - changes.Add(new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>")); + // LineDiffer caps at maxLines (default 400); the payload's Truncated flag + // tells the UI when the hunk list was clipped so the JSON stays bounded. + var diff = LineDiffer.Diff(existing, incoming); + return new FieldChange(field, $"<{oldLines} lines>", $"<{newLines} lines>", ToPayload(diff)); + } + + private static LineDiffPayload ToPayload(LineDiffResult diff) + { + var hunks = new List(diff.Lines.Count); + foreach (var line in diff.Lines) + { + hunks.Add(new LineDiffHunk(OpName(line.Op), line.Text, line.OldLineNo, line.NewLineNo)); + } + return new LineDiffPayload(hunks, diff.Truncated, diff.AddedCount, diff.RemovedCount); + } + + private static string OpName(LineDiffOp op) => op switch + { + LineDiffOp.Add => "add", + LineDiffOp.Remove => "remove", + _ => "context", + }; + + /// True if carries the named key. + private static bool HasSecretKey(SecretsBlock? secrets, string key) => + secrets is not null && secrets.Values.ContainsKey(key); + + /// + /// Records a presence-only secret change (a flip in whether a value is set on + /// each side) using the <present> / null marker convention. The + /// secret value itself is NEVER read into the diff — only its presence. + /// + private static void AddSecretPresenceChange(List changes, string field, bool existingHasSecret, bool incomingHasSecret) + { + if (existingHasSecret != incomingHasSecret) + { + changes.Add(new FieldChange(field, + existingHasSecret ? "" : null, + incomingHasSecret ? "" : null)); + } } private static void DiffChildren( @@ -392,9 +598,11 @@ public sealed class ArtifactDiff if (!incomingByName.TryGetValue(name, out var inc)) continue; if (!ScriptsEqual(ex, inc)) { - var oldLines = ex.Code.Split('\n').Length; - var newLines = inc.Code.Split('\n').Length; - changes.Add(new FieldChange($"Scripts.{name}", $"<{oldLines} lines>", $"<{newLines} lines>")); + // A script row can diverge in Code and/or its trigger/param metadata. + // The structured line diff covers the Code body (T20); when only the + // metadata changed the hunk list is all-context (no +/-), which still + // tells the operator the row changed. + changes.Add(BuildCodeFieldChange($"Scripts.{name}", ex.Code, inc.Code)); } } } @@ -451,7 +659,35 @@ public sealed class ArtifactDiff private sealed record FieldChange( [property: JsonPropertyName("field")] string Field, [property: JsonPropertyName("oldValue")] string? OldValue, - [property: JsonPropertyName("newValue")] string? NewValue); + [property: JsonPropertyName("newValue")] string? NewValue, + // Present only for code / large-text fields (T20). Null (and omitted from + // the JSON by the WhenWritingNull policy) for ordinary coarse fields. + [property: JsonPropertyName("lineDiff")] LineDiffPayload? LineDiff = null); + + /// + /// Serializable projection of embedded inside a + /// code . is in source order and + /// may be truncated (see ); / + /// always report the full diff totals. The UI (E2) + /// renders the +/- view directly from . + /// + private sealed record LineDiffPayload( + [property: JsonPropertyName("hunks")] IReadOnlyList Hunks, + [property: JsonPropertyName("truncated")] bool Truncated, + [property: JsonPropertyName("addedCount")] int AddedCount, + [property: JsonPropertyName("removedCount")] int RemovedCount); + + /// + /// One emitted line of a code line-diff. is the lower-case + /// op name (context / add / remove); line numbers are + /// 1-based and null where the side does not participate (an add has no + /// old line number, a remove has no new line number). + /// + private sealed record LineDiffHunk( + [property: JsonPropertyName("op")] string Op, + [property: JsonPropertyName("text")] string Text, + [property: JsonPropertyName("oldLineNo")] int? OldLineNo, + [property: JsonPropertyName("newLineNo")] int? NewLineNo); private sealed record FieldDiff( [property: JsonPropertyName("adds")] IReadOnlyList Adds, diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs new file mode 100644 index 00000000..9270c82b --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs @@ -0,0 +1,390 @@ +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()); +}