using ZB.MOM.WW.ScadaBridge.CentralUI.Services; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; public class BindingGroupBuilderTests { private static BindingRow Row(string canonical, string? dflt, int? conn, string? ovr) => new(canonical, dflt, conn, ovr); [Fact] public void Groups_DirectAttributesFirst_ThenModules_InNameOrder() { var rows = new[] { Row("RightMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_022.MoveInFlag"), Row("ChlorineFlow", "ChlorineFlow", 1, "Z28061.ChlorineFlow"), Row("LeftMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_023.MoveInFlag"), }; var groups = BindingGroupBuilder.Build(rows); Assert.Equal(new[] { BindingGroupBuilder.InstanceGroupKey, "LeftMESReceiver", "RightMESReceiver" }, groups.Select(g => g.Key)); } [Fact] public void NestedModule_FoldsIntoFirstSegment_AndKeepsNestedTail() { var rows = new[] { Row("LeftReactorSide.AIR_SVC", "AIR_SVC", 1, "Left_002.AIR_SVC"), Row("LeftReactorSide.LeakTest.DeltaVac", "DeltaVac", 1, "Left_002.LeakTest.DeltaVac"), }; var g = Assert.Single(BindingGroupBuilder.Build(rows)); Assert.Equal("LeftReactorSide", g.Key); Assert.Equal(new[] { "AIR_SVC", "LeakTest.DeltaVac" }, g.Members.Select(m => m.MemberTail)); Assert.Equal(new[] { "LeakTest" }, g.NestedModules); Assert.Equal("Left_002", g.InferredObject); } [Fact] public void InferredObject_NestedObjectPath() { var rows = new[] { Row("LeftDelmiaReceiver.ReadyFlag", "ReadyFlag", 1, "Left_002.DelmiaReceiver.ReadyFlag"), Row("LeftDelmiaReceiver.PartNumber", "PartNumber", 1, "Left_002.DelmiaReceiver.PartNumber"), }; Assert.Equal("Left_002.DelmiaReceiver", Assert.Single(BindingGroupBuilder.Build(rows)).InferredObject); } [Fact] public void InferredObject_NullWhenMixed_AndStateMixed() { var rows = new[] { Row("M.A", "A", 1, "Obj1.A"), Row("M.B", "B", 1, "Obj2.B"), }; var g = Assert.Single(BindingGroupBuilder.Build(rows)); Assert.Null(g.InferredObject); Assert.True(g.IsObjectMixed); } [Fact] public void InferredObject_NullWhenNoOverrides_NotMixed() { var rows = new[] { Row("M.A", "A", 1, null), Row("M.B", "B", 1, null) }; var g = Assert.Single(BindingGroupBuilder.Build(rows)); Assert.Null(g.InferredObject); Assert.False(g.IsObjectMixed); } [Fact] public void InstanceGroup_InfersObjectFromOverrides() { var rows = new[] { Row("ChlorineFlow", "ChlorineFlow", 1, "Z28061.ChlorineFlow"), Row("MachineID", "MachineID", 1, "Z28061.MachineID") }; var g = Assert.Single(BindingGroupBuilder.Build(rows)); Assert.Equal(BindingGroupBuilder.InstanceGroupKey, g.Key); Assert.Equal("Z28061", g.InferredObject); } [Fact] public void Counts_UnboundAndConnectionState() { var rows = new[] { Row("M.A", "A", 1, "O.A"), Row("M.B", "B", null, null), Row("M.C", "C", 2, "O.C"), }; var g = Assert.Single(BindingGroupBuilder.Build(rows)); Assert.Equal(3, g.Members.Count); Assert.Equal(1, g.UnboundCount); Assert.Equal(BindingConnectionState.Mixed, g.ConnectionState); Assert.Null(g.CommonConnectionId); } [Fact] public void ConnectionState_SingleWhenAllAgree() { var rows = new[] { Row("M.A", "A", 1, null), Row("M.B", "B", 1, null) }; var g = Assert.Single(BindingGroupBuilder.Build(rows)); Assert.Equal(BindingConnectionState.Single, g.ConnectionState); Assert.Equal(1, g.CommonConnectionId); } [Fact] public void MemberTail_OverrideThatDoesNotEndWithTail_CountsAsMixed() { // An operator typed a reference that does not end in the member tail — // the prefix cannot be inferred for that row, so the group is mixed. var rows = new[] { Row("M.A", "A", 1, "Obj.A"), Row("M.B", "B", 1, "Obj.Other") }; Assert.True(Assert.Single(BindingGroupBuilder.Build(rows)).IsObjectMixed); } [Fact] public void ApplyObject_RewritesEveryMember_AndSetsConnection() { var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("RightMESReceiver.MoveInFlag", "MoveInFlag", 1, "MESReceiver_024.MoveInFlag"), Row("RightMESReceiver.ScanStateCmd", "ScanStateCmd", null, null), })); var changes = BindingGroupBuilder.ApplyObject(g, connectionId: 1, objectPath: " MESReceiver_022. "); Assert.Equal(2, changes.Count); Assert.Equal(("RightMESReceiver.MoveInFlag", 1, "MESReceiver_022.MoveInFlag"), (changes[0].CanonicalName, changes[0].ConnectionId, changes[0].Override)); Assert.Equal(("RightMESReceiver.ScanStateCmd", 1, "MESReceiver_022.ScanStateCmd"), (changes[1].CanonicalName, changes[1].ConnectionId, changes[1].Override)); } [Fact] public void ApplyObject_NestedTail_KeepsNesting() { var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("LeftReactorSide.LeakTest.DeltaVac", "DeltaVac", 1, null) })); var c = Assert.Single(BindingGroupBuilder.ApplyObject(g, 1, "Left_002")); Assert.Equal("Left_002.LeakTest.DeltaVac", c.Override); } [Fact] public void ApplyObject_EmptyObject_ClearsOverrides_KeepsConnection() { var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("M.A", "A", 1, "Obj.A") })); var c = Assert.Single(BindingGroupBuilder.ApplyObject(g, 2, "")); Assert.Null(c.Override); Assert.Equal(2, c.ConnectionId); } // ── Review fixes 2026-08-20 ── [Fact] public void ApplyObject_NullConnection_KeepsEachMembersCurrentConnection() { // A header reading "— mixed —"/"— none —" (0) must not unbind the group: // Save is an upsert, so a removal could never persist anyway. var g = Assert.Single(BindingGroupBuilder.Build(new[] { Row("M.A", "A", 1, "Old.A"), Row("M.B", "B", 2, "Old.B"), Row("M.C", "C", null, null), })); var changes = BindingGroupBuilder.ApplyObject(g, connectionId: null, objectPath: "New"); Assert.Equal(new int?[] { 1, 2, null }, changes.Select(c => c.ConnectionId)); Assert.All(changes, c => Assert.StartsWith("New.", c.Override!)); } [Fact] public void InferredObject_MixedWhenSomeMembersStillOnBareDefault() { // M.B has no override and its default ("B") is not ".B" — the gate // will reject it, so the group must read mixed rather than unified on "O". var rows = new[] { Row("M.A", "A", 1, "O.A"), Row("M.B", "B", 1, null) }; var g = Assert.Single(BindingGroupBuilder.Build(rows)); Assert.Null(g.InferredObject); Assert.True(g.IsObjectMixed); } [Fact] public void InferredObject_UsesEffectiveReference_DefaultThatCarriesPrefixIsNotMixed() { var rows = new[] { Row("M.A", "A", 1, "O.A"), Row("M.B", "B", 1, null) with { DefaultReference = "O.B" } }; var g = Assert.Single(BindingGroupBuilder.Build(rows)); Assert.Equal("O", g.InferredObject); Assert.False(g.IsObjectMixed); } [Fact] public void InferredObject_NoOverrides_InfersFromDefaultsWhenTheyShareAPrefix() { var rows = new[] { Row("A", "Z28061.A", 1, null), Row("B", "Z28061.B", 1, null) }; var g = Assert.Single(BindingGroupBuilder.Build(rows)); Assert.Equal("Z28061", g.InferredObject); Assert.False(g.IsObjectMixed); } [Fact] public void IsUnbound_WhenConnectionIdIsNotAKnownConnection() { var rows = new[] { Row("M.A", "A", 99, "O.A") }; var g = Assert.Single(BindingGroupBuilder.Build(rows, knownConnectionIds: new[] { 1, 2 })); Assert.Equal(1, g.UnboundCount); Assert.Equal(BindingConnectionState.None, g.ConnectionState); } }