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