feat(transport): per-line Myers code diff + site/connection/instance compare (M8 C1, T20)
This commit is contained in:
@@ -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.
|
||||
/// <para>
|
||||
/// "Coarse" means: each persistent field is compared as a value; differing
|
||||
/// fields appear in <c>changes</c> 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 <c>changes</c> with old/new values. Code / large-
|
||||
/// text fields (script bodies, API-method scripts) instead carry a structured
|
||||
/// per-line Myers diff (T20): the <see cref="FieldChange.OldValue"/> /
|
||||
/// <see cref="FieldChange.NewValue"/> keep a compact <c><N lines></c>
|
||||
/// summary for fallback rendering, and a <see cref="FieldChange.LineDiff"/>
|
||||
/// payload carries the hunk lines (+/-/context) plus add/remove totals and a
|
||||
/// truncation flag. The diff is size-capped via <see cref="LineDiffer"/>'s
|
||||
/// <c>maxLines</c> cap so <see cref="ImportPreviewItem.FieldDiffJson"/> stays
|
||||
/// bounded.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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) ----
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming site against the existing site in the database.
|
||||
/// Identity is the <c>SiteIdentifier</c>; the diff is coarse over the
|
||||
/// display name, description, and the four cluster/gRPC node addresses.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming site from the bundle.</param>
|
||||
/// <param name="existing">The existing site in the database, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareSite(SiteDto incoming, Site? existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("Site", incoming.SiteIdentifier);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming site-scoped data connection (the <c>Sites.DataConnection</c>
|
||||
/// entity, NOT the External-System database connection) against the existing one.
|
||||
/// The Primary/Backup protocol configuration lives in the DTO's
|
||||
/// <see cref="SecretsBlock"/>, so it is compared <b>presence-only</b> — the diff
|
||||
/// never echoes the configuration value, mirroring the external-system / DB-connection
|
||||
/// secret handling.
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming data connection from the bundle.</param>
|
||||
/// <param name="existing">The existing data connection in the database, or null if new.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
public ImportPreviewItem CompareDataConnection(DataConnectionDto incoming, DataConnection? existing)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
if (existing is null) return New("DataConnection", incoming.Name);
|
||||
|
||||
var changes = new List<FieldChange>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compares an incoming deployable instance against the existing one. Identity
|
||||
/// is the <c>UniqueName</c>; the diff is coarse over the template/site/area/state
|
||||
/// scalar fields plus a name-keyed child diff over the four override/binding
|
||||
/// collections.
|
||||
/// <para>
|
||||
/// The caller is responsible for passing a <b>hydrated</b> <see cref="Instance"/>
|
||||
/// (its <c>AttributeOverrides</c>, <c>AlarmOverrides</c>, <c>NativeAlarmSourceOverrides</c>,
|
||||
/// and <c>ConnectionBindings</c> navigation collections eagerly loaded). A
|
||||
/// non-hydrated entity reads as having no children, which would surface every
|
||||
/// incoming child as an addition.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="incoming">The incoming instance from the bundle.</param>
|
||||
/// <param name="existing">The existing (hydrated) instance in the database, or null if new.</param>
|
||||
/// <param name="existingTemplateName">The existing instance's template name (the entity carries only a FK), or null if unknown.</param>
|
||||
/// <param name="existingSiteIdentifier">The existing instance's site identifier (the entity carries only a FK), or null if unknown.</param>
|
||||
/// <param name="existingAreaName">The existing instance's area name (the entity carries only a FK), or null if unknown / unset.</param>
|
||||
/// <returns>An import preview item describing the conflict type and differences.</returns>
|
||||
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<FieldChange>();
|
||||
// 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 "<id:N>" 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<FieldChange> 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 "<N lines>" 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="FieldChange"/> 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.
|
||||
/// </summary>
|
||||
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<LineDiffHunk>(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",
|
||||
};
|
||||
|
||||
/// <summary>True if <paramref name="secrets"/> carries the named key.</summary>
|
||||
private static bool HasSecretKey(SecretsBlock? secrets, string key) =>
|
||||
secrets is not null && secrets.Values.ContainsKey(key);
|
||||
|
||||
/// <summary>
|
||||
/// Records a presence-only secret change (a flip in whether a value is set on
|
||||
/// each side) using the <c><present></c> / null marker convention. The
|
||||
/// secret value itself is NEVER read into the diff — only its presence.
|
||||
/// </summary>
|
||||
private static void AddSecretPresenceChange(List<FieldChange> changes, string field, bool existingHasSecret, bool incomingHasSecret)
|
||||
{
|
||||
if (existingHasSecret != incomingHasSecret)
|
||||
{
|
||||
changes.Add(new FieldChange(field,
|
||||
existingHasSecret ? "<present>" : null,
|
||||
incomingHasSecret ? "<present>" : null));
|
||||
}
|
||||
}
|
||||
|
||||
private static void DiffChildren<TExisting, TIncoming>(
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Serializable projection of <see cref="LineDiffResult"/> embedded inside a
|
||||
/// code <see cref="FieldChange"/>. <see cref="Hunks"/> is in source order and
|
||||
/// may be truncated (see <see cref="Truncated"/>); <see cref="AddedCount"/> /
|
||||
/// <see cref="RemovedCount"/> always report the full diff totals. The UI (E2)
|
||||
/// renders the +/- view directly from <see cref="Hunks"/>.
|
||||
/// </summary>
|
||||
private sealed record LineDiffPayload(
|
||||
[property: JsonPropertyName("hunks")] IReadOnlyList<LineDiffHunk> Hunks,
|
||||
[property: JsonPropertyName("truncated")] bool Truncated,
|
||||
[property: JsonPropertyName("addedCount")] int AddedCount,
|
||||
[property: JsonPropertyName("removedCount")] int RemovedCount);
|
||||
|
||||
/// <summary>
|
||||
/// One emitted line of a code line-diff. <see cref="Op"/> is the lower-case
|
||||
/// op name (<c>context</c> / <c>add</c> / <c>remove</c>); line numbers are
|
||||
/// 1-based and null where the side does not participate (an <c>add</c> has no
|
||||
/// old line number, a <c>remove</c> has no new line number).
|
||||
/// </summary>
|
||||
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<string> Adds,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="ArtifactDiff"/>: the structured per-line Myers code diff
|
||||
/// embedded in <c>FieldDiffJson</c> (T20) and the M8 Site / DataConnection /
|
||||
/// Instance compares. The diff records are private, so assertions parse the
|
||||
/// serialized <see cref="ImportPreviewItem.FieldDiffJson"/>.
|
||||
/// </summary>
|
||||
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 "<N lines>"-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<string, string>
|
||||
{
|
||||
["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("<present>", 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("<present>", 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>", added.GetProperty("newValue").GetString());
|
||||
|
||||
var modified = ChangeFor(item, "AttributeOverrides.Scale");
|
||||
Assert.Equal("<modified>", modified.GetProperty("oldValue").GetString());
|
||||
Assert.Equal("<modified>", modified.GetProperty("newValue").GetString());
|
||||
}
|
||||
|
||||
// ---- builders ----
|
||||
|
||||
private static Commons.Entities.Templates.Template MakeTemplate(string name) =>
|
||||
new(name);
|
||||
|
||||
private static TemplateDto MakeTemplateDto(string name, IReadOnlyList<TemplateScriptDto>? scripts = null) =>
|
||||
new(
|
||||
Name: name,
|
||||
FolderName: null,
|
||||
BaseTemplateName: null,
|
||||
Description: null,
|
||||
Attributes: Array.Empty<TemplateAttributeDto>(),
|
||||
Alarms: Array.Empty<TemplateAlarmDto>(),
|
||||
Scripts: scripts ?? Array.Empty<TemplateScriptDto>(),
|
||||
Compositions: Array.Empty<TemplateCompositionDto>());
|
||||
|
||||
private static InstanceDto MakeInstanceDto(
|
||||
string uniqueName,
|
||||
string template = "Tmpl",
|
||||
string site = "site-a",
|
||||
InstanceState state = InstanceState.Enabled,
|
||||
IReadOnlyList<InstanceAttributeOverrideDto>? attributeOverrides = null) =>
|
||||
new(
|
||||
UniqueName: uniqueName,
|
||||
TemplateName: template,
|
||||
SiteIdentifier: site,
|
||||
AreaName: null,
|
||||
State: state,
|
||||
AttributeOverrides: attributeOverrides ?? Array.Empty<InstanceAttributeOverrideDto>(),
|
||||
AlarmOverrides: Array.Empty<InstanceAlarmOverrideDto>(),
|
||||
NativeAlarmSourceOverrides: Array.Empty<InstanceNativeAlarmSourceOverrideDto>(),
|
||||
ConnectionBindings: Array.Empty<InstanceConnectionBindingDto>());
|
||||
}
|
||||
Reference in New Issue
Block a user