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());
+}