using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
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.Flattening;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Flattening;
public class FlatteningServiceMergeTests
{
// ── MergeHiLoConfig ────────────────────────────────────────────────────
/// Verifies that merging prefers derived keys while inherited keys that are not overridden survive.
[Fact]
public void MergeHiLoConfig_DerivedKeysWin_InheritedKeysSurvive()
{
const string inherited = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}";
const string derived = @"{""hi"":90}"; // derived only overrides Hi
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
Assert.NotNull(result);
using var doc = JsonDocument.Parse(result!);
Assert.Equal("Temp", doc.RootElement.GetProperty("attributeName").GetString());
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
}
/// Verifies that a derived config can override the attribute name.
[Fact]
public void MergeHiLoConfig_DerivedCanOverrideAttribute()
{
const string inherited = @"{""attributeName"":""Temp"",""hi"":80}";
const string derived = @"{""attributeName"":""Pressure"",""hi"":50}";
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
using var doc = JsonDocument.Parse(result!);
Assert.Equal("Pressure", doc.RootElement.GetProperty("attributeName").GetString());
Assert.Equal(50, doc.RootElement.GetProperty("hi").GetDouble());
}
/// Verifies that merging returns the inherited config when the derived config is null.
[Fact]
public void MergeHiLoConfig_DerivedNull_ReturnsInherited()
{
const string inherited = @"{""hi"":80}";
var result = FlatteningService.MergeHiLoConfig(inherited, null);
Assert.Equal(inherited, result);
}
/// Verifies that merging returns the derived config when the inherited config is null.
[Fact]
public void MergeHiLoConfig_InheritedNull_ReturnsDerived()
{
const string derived = @"{""hi"":80}";
var result = FlatteningService.MergeHiLoConfig(null, derived);
Assert.Equal(derived, result);
}
/// Verifies that merging returns null when both configs are null.
[Fact]
public void MergeHiLoConfig_BothNull_ReturnsNull()
{
Assert.Null(FlatteningService.MergeHiLoConfig(null, null));
}
/// Verifies that merging falls back to the derived config when the inherited config is malformed.
[Fact]
public void MergeHiLoConfig_MalformedInherited_FallsBackToDerived()
{
// Safe fallback — never throw on malformed input.
const string derived = @"{""hi"":80}";
var result = FlatteningService.MergeHiLoConfig("{not valid", derived);
Assert.Equal(derived, result);
}
/// Verifies that a new derived key is added while the remaining inherited keys are preserved.
[Fact]
public void MergeHiLoConfig_DerivedAddsNewKey_PreservesInheritedRest()
{
// Derived adds a deadband that the base didn't have.
const string inherited = @"{""hi"":80,""hiHi"":100}";
const string derived = @"{""hiDeadband"":3}";
var result = FlatteningService.MergeHiLoConfig(inherited, derived);
using var doc = JsonDocument.Parse(result!);
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble());
Assert.Equal(3, doc.RootElement.GetProperty("hiDeadband").GetDouble());
}
// ── DiffHiLoConfig ─────────────────────────────────────────────────────
/// Verifies that diffing identical configs returns null.
[Fact]
public void DiffHiLoConfig_NoChanges_ReturnsNull()
{
const string both = @"{""attributeName"":""Temp"",""hi"":80}";
Assert.Null(FlatteningService.DiffHiLoConfig(both, both));
}
/// Verifies that diffing returns only the key that changed.
[Fact]
public void DiffHiLoConfig_ChangedKey_ReturnsOnlyChangedKey()
{
const string inherited = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}";
const string edited = @"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":90,""hiHi"":100}";
var diff = FlatteningService.DiffHiLoConfig(inherited, edited);
Assert.NotNull(diff);
using var doc = JsonDocument.Parse(diff!);
var prop = Assert.Single(doc.RootElement.EnumerateObject());
Assert.Equal("hi", prop.Name);
Assert.Equal(90, prop.Value.GetDouble());
}
/// Verifies that a newly added key appears in the diff.
[Fact]
public void DiffHiLoConfig_NewKey_AddedToDiff()
{
const string inherited = @"{""attributeName"":""Temp"",""hi"":80}";
const string edited = @"{""attributeName"":""Temp"",""hi"":80,""hiDeadband"":3}";
var diff = FlatteningService.DiffHiLoConfig(inherited, edited);
Assert.NotNull(diff);
using var doc = JsonDocument.Parse(diff!);
Assert.Equal(3, doc.RootElement.GetProperty("hiDeadband").GetDouble());
Assert.False(doc.RootElement.TryGetProperty("hi", out _));
}
/// Verifies that diffing against a null inherited config returns the edited config verbatim.
[Fact]
public void DiffHiLoConfig_NullInherited_ReturnsEditedVerbatim()
{
const string edited = @"{""attributeName"":""Temp"",""hi"":80}";
Assert.Equal(edited, FlatteningService.DiffHiLoConfig(null, edited));
}
/// Verifies that diffing a null edited config returns null.
[Fact]
public void DiffHiLoConfig_NullEdited_ReturnsNull()
{
Assert.Null(FlatteningService.DiffHiLoConfig(@"{""hi"":80}", null));
}
/// Verifies that diffing ignores differences in string escaping.
[Fact]
public void DiffHiLoConfig_IgnoresStringEscapeDifferences()
{
// Inherited has literal em-dash; edited has the unicode-escaped form.
// Decoded values are identical, so the key should NOT be in the diff.
const string inherited = @"{""attributeName"":""Temp"",""hi"":80,""hiMessage"":""High — investigate""}";
const string edited = @"{""attributeName"":""Temp"",""hi"":80,""hiMessage"":""High — investigate""}";
var diff = FlatteningService.DiffHiLoConfig(inherited, edited);
Assert.Null(diff); // no real change once values are decoded
}
/// Verifies that diffing ignores differences in numeric formatting.
[Fact]
public void DiffHiLoConfig_IgnoresNumericFormatDifferences()
{
// 85 vs 85.0 are the same number — should not produce a diff.
const string inherited = @"{""hi"":85}";
const string edited = @"{""hi"":85.0}";
Assert.Null(FlatteningService.DiffHiLoConfig(inherited, edited));
}
/// Verifies that a diff merged back onto the inherited config reproduces the edited config.
[Fact]
public void DiffHiLoConfig_RoundTripsThroughMerge()
{
// Merge(inherited, Diff(inherited, edited)) ≡ edited — when the
// edited config is itself a superset/equivalent of inherited.
const string inherited = @"{""attributeName"":""Temp"",""hi"":80,""hiHi"":100}";
const string edited = @"{""attributeName"":""Temp"",""hi"":90,""hiHi"":100,""hiDeadband"":5}";
var diff = FlatteningService.DiffHiLoConfig(inherited, edited);
var merged = FlatteningService.MergeHiLoConfig(inherited, diff);
using var origDoc = JsonDocument.Parse(edited);
using var mergedDoc = JsonDocument.Parse(merged!);
Assert.Equal(origDoc.RootElement.GetProperty("hi").GetDouble(),
mergedDoc.RootElement.GetProperty("hi").GetDouble());
Assert.Equal(origDoc.RootElement.GetProperty("hiHi").GetDouble(),
mergedDoc.RootElement.GetProperty("hiHi").GetDouble());
Assert.Equal(origDoc.RootElement.GetProperty("hiDeadband").GetDouble(),
mergedDoc.RootElement.GetProperty("hiDeadband").GetDouble());
}
// ── Instance-level alarm override (end-to-end Flatten) ─────────────────
private static (Template, Instance) BuildHiLoFixture(string inheritedJson, InstanceAlarmOverride? ovr = null, bool locked = false)
{
var template = new Template("PumpTpl")
{
Id = 1,
Alarms = new List
{
new("Temp")
{
Id = 10,
TemplateId = 1,
TriggerType = AlarmTriggerType.HiLo,
TriggerConfiguration = inheritedJson,
PriorityLevel = 500,
IsLocked = locked
}
}
};
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
if (ovr != null) instance.AlarmOverrides.Add(ovr);
return (template, instance);
}
private static FlattenedConfiguration Flatten(Template template, Instance instance)
{
var sut = new FlatteningService();
var result = sut.Flatten(
instance,
new[] { template },
new Dictionary>(),
new Dictionary>(),
new Dictionary());
if (!result.IsSuccess) Assert.Fail(result.Error);
return result.Value!;
}
/// Verifies that an instance alarm override merges its high/low setpoints into the flattened alarm.
[Fact]
public void Flatten_InstanceAlarmOverride_HiLo_MergesSetpoints()
{
// Template has {hi=80, hiHi=100, lo=10, loLo=0}. Instance overrides hi=90 only.
var (tpl, inst) = BuildHiLoFixture(
@"{""attributeName"":""Temp"",""loLo"":0,""lo"":10,""hi"":80,""hiHi"":100}",
new InstanceAlarmOverride("Temp")
{
InstanceId = 100,
TriggerConfigurationOverride = @"{""hi"":90}"
});
var flat = Flatten(tpl, inst);
var alarm = flat.Alarms.Single();
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
Assert.Equal(0, doc.RootElement.GetProperty("loLo").GetDouble());
Assert.Equal(10, doc.RootElement.GetProperty("lo").GetDouble());
Assert.Equal(90, doc.RootElement.GetProperty("hi").GetDouble()); // overridden
Assert.Equal(100, doc.RootElement.GetProperty("hiHi").GetDouble()); // inherited
Assert.Equal("Override", alarm.Source);
}
/// Verifies that an instance alarm override replaces the alarm priority.
[Fact]
public void Flatten_InstanceAlarmOverride_OverridesPriority()
{
var (tpl, inst) = BuildHiLoFixture(
@"{""attributeName"":""Temp"",""hi"":80}",
new InstanceAlarmOverride("Temp")
{
InstanceId = 100,
PriorityLevelOverride = 950
});
var flat = Flatten(tpl, inst);
var alarm = flat.Alarms.Single();
Assert.Equal(950, alarm.PriorityLevel);
Assert.Equal("Override", alarm.Source);
}
/// Verifies that an override targeting a locked alarm is silently ignored.
[Fact]
public void Flatten_InstanceAlarmOverride_LockedAlarm_OverrideSilentlyIgnored()
{
// Locked alarm — override should be a no-op at flatten time. (The
// InstanceService.SetAlarmOverrideAsync write-time check is what
// prevents the override from being persisted in the first place;
// this test covers the runtime safety net.)
var (tpl, inst) = BuildHiLoFixture(
@"{""attributeName"":""Temp"",""hi"":80}",
new InstanceAlarmOverride("Temp")
{
InstanceId = 100,
TriggerConfigurationOverride = @"{""hi"":999}"
},
locked: true);
var flat = Flatten(tpl, inst);
var alarm = flat.Alarms.Single();
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble()); // not overridden
}
/// Verifies that an override naming an unknown alarm has no effect.
[Fact]
public void Flatten_InstanceAlarmOverride_UnknownName_DoesNothing()
{
// Override targets an alarm name that doesn't exist on the template —
// silently ignored (same behavior as attribute overrides).
var (tpl, inst) = BuildHiLoFixture(
@"{""attributeName"":""Temp"",""hi"":80}",
new InstanceAlarmOverride("DoesNotExist")
{
InstanceId = 100,
TriggerConfigurationOverride = @"{""hi"":999}"
});
var flat = Flatten(tpl, inst);
var alarm = flat.Alarms.Single();
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
Assert.Equal(80, doc.RootElement.GetProperty("hi").GetDouble());
Assert.NotEqual("Override", alarm.Source);
}
/// Verifies that an override on a binary-trigger alarm replaces the whole trigger config.
[Fact]
public void Flatten_InstanceAlarmOverride_BinaryTrigger_ReplacesWholeConfig()
{
// For non-HiLo trigger types, an instance override replaces the whole
// TriggerConfiguration (no per-key merge).
var template = new Template("PumpTpl")
{
Id = 1,
Alarms = new List
{
new("Temp")
{
Id = 10,
TemplateId = 1,
TriggerType = AlarmTriggerType.RangeViolation,
TriggerConfiguration = @"{""attributeName"":""T"",""min"":0,""max"":100}",
PriorityLevel = 500
}
}
};
var instance = new Instance("Pump-1") { Id = 100, TemplateId = 1, SiteId = 1 };
instance.AlarmOverrides.Add(new InstanceAlarmOverride("Temp")
{
InstanceId = 100,
TriggerConfigurationOverride = @"{""attributeName"":""T"",""min"":-50,""max"":50}"
});
var flat = Flatten(template, instance);
var alarm = flat.Alarms.Single();
using var doc = JsonDocument.Parse(alarm.TriggerConfiguration!);
Assert.Equal(-50, doc.RootElement.GetProperty("min").GetDouble());
Assert.Equal(50, doc.RootElement.GetProperty("max").GetDouble());
}
}