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