Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/ArtifactDiffTests.cs
T
Joseph Doherty 41a5ecf14a feat(external-systems): deploy per-system retry policy to sites + CLI/management surface (register row 27)
An external system's MaxRetries/RetryDelay existed on the central entity but never
reached a site and had no management surface, so every ExternalSystem.CachedCall
buffered with the S&F engine default (DefaultMaxRetries 50 x DefaultRetryInterval
30s ~= 25 min to park) whatever central had configured. The sibling
DatabaseConnectionArtifact had always carried its equivalents.

The consumption end was already wired: ExternalSystemClient.CachedCallAsync already
read system.MaxRetries/RetryDelay and passed them to EnqueueAsync with the 0-as-unset
convention. Only the transport and persistence were missing.

Commons
- ExternalSystemArtifact gains MaxRetries/RetryDelay as optional positional params
  (0 / TimeSpan.Zero = unset), matching DatabaseConnectionArtifact rather than going
  nullable, so it maps 1:1 onto the entity and the proto3 scalars.
- Create/UpdateExternalSystemCommand gain nullable MaxRetries/RetryDelay, following
  the existing TimeoutSeconds "omit to preserve" convention. Both additive.

Central
- ManagementActor create maps both; update preserves-if-null.
- ArtifactDeploymentService projects them onto the artifact.

Wire
- site_command.proto ExternalSystemArtifactDto gains max_retries=7 /
  retry_delay=8 (google.protobuf.Duration), mirroring DatabaseConnectionArtifactDto.
  Additive: a pre-existing site decodes them as 0/absent, which IS the unset value,
  so old behaviour is preserved. Vendored C# regenerated via docker/regen-proto.sh
  sitecommand; csproj Protobuf item re-commented, no message types added or removed.

Site
- external_systems gains max_retries / retry_delay_ms via AddColumnIfMissing,
  DEFAULT 0 = unset. Storage upsert, repository SELECT/mapping (new ordinals 6/7
  appended so existing ones are untouched), apply path, and the legacy migrator's
  explicit column list all carry them.

Replicated-table decision: external_systems IS in SiteLocalDbSetup.ReplicatedTables,
so this was checked against the AckTime/native_alarm_state precedent. Real columns
are correct here, not a JSON bag: the table has no general-purpose config column
(auth_configuration and method_definitions are both semantically specific),
timeout_seconds already set the additive-column precedent on this exact table, and
sibling database_connections stores the same two values as plain columns. Verified in
LocalDb source that RegisterReplicated re-reads PRAGMA table_info every boot and its
install script is DROP-then-CREATE, so capture triggers rebuild over the new column
set automatically, and SiteStorageSchema.Apply runs before registration on the same
boot. The only cost is the ReplicatedTable.ComputeDigest change, which the sync
handshake compares fail-closed - a mixed-version pair refuses to sync loudly instead
of diverging silently. Both-nodes-together is already the standing site-pair rule, so
no new operational constraint; recorded in topology-guide.md.

Also fixed: ArtifactDiff.CompareExternalSystem never compared MaxRetries/RetryDelay,
so a bundle import that changed a system's retry budget previewed as "Identical" -
harmless while the values were inert, a live behaviour change now.

No EF migration: the central ExternalSystemDefinitions columns already exist
(InitialSchema). Central UI already had both form fields.

Tests: site storage round-trip + unset-default, site apply path, central artifact
projection, proto round-trip (via the shared sample), management create/preserve,
CLI option parse + alias, and diff reporting. Fail-before verified on the two core
tests (expected 7 got 0 on the read path; expected 9 got 0 on the apply path).

Docs: Component-ExternalSystemGateway, Component-StoreAndForward, Component-CLI,
components/StoreAndForward, components/SiteRuntime, deployment/topology-guide,
CLI README (which was also missing --timeout-seconds entirely).
2026-08-15 12:56:12 -04:00

650 lines
26 KiB
C#

using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;
using ZB.MOM.WW.ScadaBridge.Transport.Serialization;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
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);
}
// ============ Deferred-work row 27: ES retry policy in the diff ============
/// <summary>
/// The bundle has always CARRIED the external-system retry policy and
/// <c>BundleImporter</c> has always APPLIED it, but <c>CompareExternalSystem</c> never
/// compared it — so an import that silently changed a system's retry budget previewed as
/// "Identical". Now that the policy actually reaches sites, an unreviewed change to it is a
/// live behaviour change, so it must show up in the preview.
/// </summary>
[Fact]
public void ExternalSystem_ChangedRetryPolicy_IsReported()
{
var existing = new ExternalSystemDefinition("Mes", "https://mes", "ApiKey")
{
MaxRetries = 3,
RetryDelay = TimeSpan.FromSeconds(5),
};
var incoming = new ExternalSystemDto("Mes", "https://mes", "ApiKey",
MaxRetries: 9, RetryDelay: TimeSpan.FromSeconds(45), Methods: [], Secrets: null);
var item = _diff.CompareExternalSystem(incoming, existing, []);
Assert.Equal(ConflictKind.Modified, item.Kind);
var maxRetries = ChangeFor(item, "MaxRetries");
Assert.Equal("3", maxRetries.GetProperty("oldValue").GetString());
Assert.Equal("9", maxRetries.GetProperty("newValue").GetString());
var retryDelay = ChangeFor(item, "RetryDelay");
Assert.Equal(TimeSpan.FromSeconds(5).ToString(), retryDelay.GetProperty("oldValue").GetString());
Assert.Equal(TimeSpan.FromSeconds(45).ToString(), retryDelay.GetProperty("newValue").GetString());
}
/// <summary>An unchanged retry policy must not manufacture a spurious diff row.</summary>
[Fact]
public void ExternalSystem_UnchangedRetryPolicy_IsIdentical()
{
var existing = new ExternalSystemDefinition("Mes", "https://mes", "ApiKey")
{
MaxRetries = 3,
RetryDelay = TimeSpan.FromSeconds(5),
};
var incoming = new ExternalSystemDto("Mes", "https://mes", "ApiKey",
MaxRetries: 3, RetryDelay: TimeSpan.FromSeconds(5), Methods: [], Secrets: null);
var item = _diff.CompareExternalSystem(incoming, existing, []);
Assert.Equal(ConflictKind.Identical, item.Kind);
}
[Fact]
public void ApiMethod_ModifiedScript_EmitsStructuredLineDiff()
{
var existing = new ApiMethod("doThing", "log(\"a\");") { TimeoutSeconds = 30 };
var incoming = new ApiMethodDto(
Name: "doThing",
Script: "log(\"a\");\nlog(\"b\");",
ParameterDefinitions: existing.ParameterDefinitions,
ReturnDefinition: existing.ReturnDefinition,
TimeoutSeconds: 30);
var item = _diff.CompareApiMethod(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Script");
var lineDiff = change.GetProperty("lineDiff");
Assert.Equal(1, lineDiff.GetProperty("addedCount").GetInt32());
Assert.Equal(0, lineDiff.GetProperty("removedCount").GetInt32());
var hunks = Hunks(change);
Assert.Contains(("context", "log(\"a\");"), hunks);
Assert.Contains(("add", "log(\"b\");"), hunks);
}
[Fact]
public void TemplateScript_ModifiedCode_EmitsStructuredLineDiff()
{
var existing = MakeTemplate("T1");
existing.Scripts.Add(new TemplateScript("onScan", "old1\nold2"));
var incoming = MakeTemplateDto("T1", scripts:
[
new TemplateScriptDto("onScan", "old1\nnew2", null, null, null, null, false, null),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Scripts.onScan");
var hunks = Hunks(change);
Assert.Contains(("context", "old1"), hunks);
Assert.Contains(("remove", "old2"), hunks);
Assert.Contains(("add", "new2"), hunks);
}
// ============ #05-T23: placeholder-identity resolution via name maps ============
[Fact]
public void CompareTemplate_MatchingFolderBaseAndComposition_IsIdentical()
{
// A structured template (FolderId, ParentTemplateId, one composition) that
// the incoming bundle matches by NAME must classify Identical. Before T23,
// FolderNameOf/BaseTemplateNameOf/CompositionTargetNameOf returned "<id:N>"
// placeholders that never equalled the incoming names, so every re-import
// of an unchanged structured template spuriously read Modified.
var existing = MakeTemplate("Pump");
existing.FolderId = 5;
existing.ParentTemplateId = 7;
existing.Compositions.Add(new TemplateComposition("Mod1") { ComposedTemplateId = 9 });
var folderNameById = new Dictionary<int, string> { [5] = "Machines" };
var templateNameById = new Dictionary<int, string> { [7] = "BasePump", [9] = "FeatureX" };
var incoming = MakeTemplateDto("Pump") with
{
FolderName = "Machines",
BaseTemplateName = "BasePump",
Compositions = new[] { new TemplateCompositionDto("Mod1", "FeatureX") },
};
var item = _diff.CompareTemplate(incoming, existing, folderNameById, templateNameById);
Assert.Equal(ConflictKind.Identical, item.Kind);
}
[Fact]
public void CompareTemplate_DivergentBaseTemplateName_IsModified()
{
// Sanity: the maps must not mask a REAL change. Existing base = "BasePump",
// incoming names a different base → Modified.
var existing = MakeTemplate("Pump");
existing.ParentTemplateId = 7;
var templateNameById = new Dictionary<int, string> { [7] = "BasePump" };
var incoming = MakeTemplateDto("Pump") with { BaseTemplateName = "DifferentBase" };
var item = _diff.CompareTemplate(incoming, existing, folderNameById: null, templateNameById: templateNameById);
Assert.Equal(ConflictKind.Modified, item.Kind);
}
// ============ #05-T6: single-source child equality — diff sees the full field set ============
[Fact]
public void CompareTemplate_AttributeElementDataTypeChange_IsModified()
{
var existing = MakeTemplate("T1");
existing.Attributes.Add(new TemplateAttribute("L")
{
DataType = DataType.List,
ElementDataType = DataType.Int32,
Value = "[1,2]",
});
var incoming = MakeTemplateDto("T1", attributes:
[
new TemplateAttributeDto("L", "[1,2]", DataType.List, false, null, null, DataType.String),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
ChangeFor(item, "Attributes.L"); // ElementDataType diverged — previously missed.
}
[Fact]
public void CompareTemplate_LockedInDerivedChange_IsModified()
{
var existing = MakeTemplate("T1");
existing.Attributes.Add(new TemplateAttribute("L")
{
DataType = DataType.Int32,
Value = "1",
LockedInDerived = false,
});
var incoming = MakeTemplateDto("T1", attributes:
[
new TemplateAttributeDto("L", "1", DataType.Int32, false, null, null, null, LockedInDerived: true),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
ChangeFor(item, "Attributes.L"); // LockedInDerived diverged — previously missed.
}
[Fact]
public void CompareTemplate_ScriptExecutionTimeoutChange_IsModified()
{
var existing = MakeTemplate("T1");
existing.Scripts.Add(new TemplateScript("S", "return 1;") { ExecutionTimeoutSeconds = 30 });
var incoming = MakeTemplateDto("T1", scripts:
[
// identical code; only ExecutionTimeoutSeconds differs.
new TemplateScriptDto("S", "return 1;", null, null, null, null, false, null, ExecutionTimeoutSeconds: 60),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
ChangeFor(item, "Scripts.S"); // ExecutionTimeoutSeconds diverged — previously missed.
}
[Fact]
public void CompareTemplate_AlarmOnTriggerScriptChange_IsModified()
{
var existing = MakeTemplate("T1");
existing.Scripts.Add(new TemplateScript("Run", "return 1;") { Id = 1 });
existing.Alarms.Add(new TemplateAlarm("A")
{
TriggerType = AlarmTriggerType.RangeViolation,
OnTriggerScriptId = 1, // resolves to "Run"
});
var incoming = MakeTemplateDto("T1",
scripts: [new TemplateScriptDto("Run", "return 1;", null, null, null, null, false, null)],
alarms:
[
// same alarm fields but no on-trigger script (was "Run").
new TemplateAlarmDto("A", null, 0, AlarmTriggerType.RangeViolation, null, false, OnTriggerScriptName: null),
]);
var item = _diff.CompareTemplate(incoming, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
ChangeFor(item, "Alarms.A"); // OnTriggerScriptName diverged — previously missed.
}
// ============ M8: CompareSite ============
[Fact]
public void CompareSite_ExistingNull_New()
{
var dto = new SiteDto("site-a", "Site A", null, null, null, null, null);
var item = _diff.CompareSite(dto, existing: null);
Assert.Equal(ConflictKind.New, item.Kind);
Assert.Equal("Site", item.EntityType);
Assert.Equal("site-a", item.Name);
Assert.Null(item.FieldDiffJson);
}
[Fact]
public void CompareSite_FieldDiffers_Modified()
{
var existing = new Site("Site A", "site-a") { Description = "old", NodeAAddress = "10.0.0.1" };
var dto = new SiteDto("site-a", "Site A", "new", "10.0.0.1", null, null, null);
var item = _diff.CompareSite(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Description");
Assert.Equal("old", change.GetProperty("oldValue").GetString());
Assert.Equal("new", change.GetProperty("newValue").GetString());
}
[Fact]
public void CompareSite_NoChanges_Identical()
{
var existing = new Site("Site A", "site-a")
{
Description = "d",
NodeAAddress = "a",
NodeBAddress = "b",
GrpcNodeAAddress = "ga",
GrpcNodeBAddress = "gb",
};
var dto = new SiteDto("site-a", "Site A", "d", "a", "b", "ga", "gb");
var item = _diff.CompareSite(dto, existing);
Assert.Equal(ConflictKind.Identical, item.Kind);
Assert.Null(item.FieldDiffJson);
}
// ============ M8: CompareDataConnection ============
[Fact]
public void CompareDataConnection_ExistingNull_New()
{
var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3, null);
var item = _diff.CompareDataConnection(dto, existing: null);
Assert.Equal(ConflictKind.New, item.Kind);
Assert.Equal("DataConnection", item.EntityType);
Assert.Equal("opc1", item.Name);
}
[Fact]
public void CompareDataConnection_ProtocolDiffers_Modified()
{
var existing = new DataConnection("opc1", "OpcUa", siteId: 1) { FailoverRetryCount = 3 };
var dto = new DataConnectionDto("site-a", "opc1", "MxAccess", 3, null);
var item = _diff.CompareDataConnection(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Protocol");
Assert.Equal("OpcUa", change.GetProperty("oldValue").GetString());
Assert.Equal("MxAccess", change.GetProperty("newValue").GetString());
}
[Fact]
public void CompareDataConnection_SecretValueChangesButPresencePreserved_NoSecretEcho()
{
// Both sides HAVE a primary configuration secret; only the value differs.
// Presence-only comparison => no Secrets.* change, and the value is never echoed.
var existing = new DataConnection("opc1", "OpcUa", siteId: 1)
{
FailoverRetryCount = 3,
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://OLD:4840\"}",
};
var dto = new DataConnectionDto("site-a", "opc1", "OpcUa", 3,
new SecretsBlock(new Dictionary<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!);
}
// ============ SMS: CompareSmsConfiguration (S10b) ============
private static SmsConfiguration ExistingSms() =>
new("AC1", "+15550000000") { Id = 1, AuthToken = "stored-token" };
private static SmsConfigDto SmsDto(
string from = "+15550000000",
string? msgSid = null,
bool withSecret = true) =>
new("AC1", from, msgSid, ApiBaseUrl: null, ConnectionTimeoutSeconds: 30,
MaxRetries: 10, RetryDelay: TimeSpan.FromMinutes(1),
Secrets: withSecret
? new SecretsBlock(new Dictionary<string, string> { ["AuthToken"] = "wire-token" })
: null);
[Fact]
public void CompareSmsConfiguration_ExistingNull_New()
{
var item = _diff.CompareSmsConfiguration(SmsDto(), existing: null);
Assert.Equal(ConflictKind.New, item.Kind);
Assert.Equal("SmsConfiguration", item.EntityType);
Assert.Equal("AC1", item.Name);
}
[Fact]
public void CompareSmsConfiguration_AllFieldsAndPresenceMatch_Identical()
{
var item = _diff.CompareSmsConfiguration(SmsDto(), ExistingSms());
Assert.Equal(ConflictKind.Identical, item.Kind);
Assert.Null(item.FieldDiffJson);
}
[Fact]
public void CompareSmsConfiguration_FromNumberDiffers_Modified()
{
var item = _diff.CompareSmsConfiguration(SmsDto(from: "+15559999999"), ExistingSms());
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "FromNumber");
Assert.Equal("+15550000000", change.GetProperty("oldValue").GetString());
Assert.Equal("+15559999999", change.GetProperty("newValue").GetString());
}
[Fact]
public void CompareSmsConfiguration_AuthTokenValueChangesButPresencePreserved_NoSecretEcho()
{
// Both sides carry a token; only the value differs. Presence-only comparison =>
// no Secrets.AuthToken change, and neither token value is ever echoed.
var item = _diff.CompareSmsConfiguration(SmsDto(withSecret: true), ExistingSms());
Assert.Equal(ConflictKind.Identical, item.Kind);
var json = item.FieldDiffJson ?? string.Empty;
Assert.DoesNotContain("stored-token", json);
Assert.DoesNotContain("wire-token", json);
}
[Fact]
public void CompareSmsConfiguration_AuthTokenPresenceFlips_ShowsPresentMarkerOnly()
{
// Existing has a token; incoming carries no secret => a presence change that
// surfaces only the "<present>" marker, never the token value.
var item = _diff.CompareSmsConfiguration(SmsDto(withSecret: false), ExistingSms());
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "Secrets.AuthToken");
Assert.Equal("<present>", change.GetProperty("oldValue").GetString());
Assert.False(change.TryGetProperty("newValue", out _));
Assert.DoesNotContain("stored-token", item.FieldDiffJson!);
}
// ============ M8: CompareInstance ============
[Fact]
public void CompareInstance_ExistingNull_New()
{
var dto = MakeInstanceDto("inst-1");
var item = _diff.CompareInstance(dto, existing: null);
Assert.Equal(ConflictKind.New, item.Kind);
Assert.Equal("Instance", item.EntityType);
Assert.Equal("inst-1", item.Name);
}
[Fact]
public void CompareInstance_StateDiffers_Modified()
{
var existing = new Instance("inst-1") { State = InstanceState.Disabled };
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled);
var item = _diff.CompareInstance(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var change = ChangeFor(item, "State");
Assert.Equal("Disabled", change.GetProperty("oldValue").GetString());
Assert.Equal("Enabled", change.GetProperty("newValue").GetString());
}
[Fact]
public void CompareInstance_NoChanges_Identical()
{
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled);
var item = _diff.CompareInstance(dto, existing);
Assert.Equal(ConflictKind.Identical, item.Kind);
Assert.Null(item.FieldDiffJson);
}
[Fact]
public void CompareInstance_TemplateAndSiteNamesDiffer_Modified()
{
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
var dto = MakeInstanceDto("inst-1", template: "TmplB", site: "site-b", state: InstanceState.Enabled);
var item = _diff.CompareInstance(dto, existing,
existingTemplateName: "TmplA", existingSiteIdentifier: "site-a");
Assert.Equal(ConflictKind.Modified, item.Kind);
Assert.Equal("TmplA", ChangeFor(item, "TemplateName").GetProperty("oldValue").GetString());
Assert.Equal("TmplB", ChangeFor(item, "TemplateName").GetProperty("newValue").GetString());
Assert.Equal("site-a", ChangeFor(item, "SiteIdentifier").GetProperty("oldValue").GetString());
Assert.Equal("site-b", ChangeFor(item, "SiteIdentifier").GetProperty("newValue").GetString());
}
[Fact]
public void CompareInstance_ChildOverridesAddedRemovedModified()
{
var existing = new Instance("inst-1") { State = InstanceState.Enabled };
// Will be REMOVED (present existing, absent incoming).
existing.AttributeOverrides.Add(new InstanceAttributeOverride("Setpoint") { OverrideValue = "10" });
// Will be MODIFIED (present both, value differs).
existing.AttributeOverrides.Add(new InstanceAttributeOverride("Scale") { OverrideValue = "1.0" });
var dto = MakeInstanceDto("inst-1", state: InstanceState.Enabled, attributeOverrides:
[
new InstanceAttributeOverrideDto("Scale", "2.0", null), // modified
new InstanceAttributeOverrideDto("Offset", "5", null), // added
]);
var item = _diff.CompareInstance(dto, existing);
Assert.Equal(ConflictKind.Modified, item.Kind);
var removed = ChangeFor(item, "AttributeOverrides.Setpoint");
Assert.Equal("<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,
IReadOnlyList<TemplateAttributeDto>? attributes = null,
IReadOnlyList<TemplateAlarmDto>? alarms = null) =>
new(
Name: name,
FolderName: null,
BaseTemplateName: null,
Description: null,
Attributes: attributes ?? Array.Empty<TemplateAttributeDto>(),
Alarms: 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>());
}