From 5da7003e884367b604efe08e2b428e7a2faa5ae8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 15:53:59 -0400 Subject: [PATCH] =?UTF-8?q?feat(centralui):=20BindingGroupBuilder=20?= =?UTF-8?q?=E2=80=94=20group=20flattened=20binding=20rows=20by=20module,?= =?UTF-8?q?=20infer=20the=20shared=20MxAccess=20object=20prefix,=20count?= =?UTF-8?q?=20unbound?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/BindingGroupBuilder.cs | 117 ++++++++++++++++++ .../Services/BindingGroupBuilderTests.cs | 114 +++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs new file mode 100644 index 00000000..43b2cb77 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs @@ -0,0 +1,117 @@ +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// One data-sourced flattened attribute as the Configure page sees it. +/// Path-qualified flattened name, e.g. LeftMESReceiver.MoveInFlag. +/// Template/composed default reference (pre-override), may be null. +/// Bound data connection id, or null when unbound. +/// Per-instance reference override, or null when the default applies. +public sealed record BindingRow(string CanonicalName, string? DefaultReference, int? ConnectionId, string? Override); + +public enum BindingConnectionState { None, Single, Mixed } + +/// A member of a : the row plus its tail relative to the group. +public sealed record BindingGroupMember(BindingRow Row, string MemberTail) +{ + public bool IsUnbound => Row.ConnectionId is null or <= 0; + /// Effective reference: override if set, else the default. + public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override; +} + +/// +/// One module's worth of binding rows. is the first segment of the +/// canonical name ( for direct +/// attributes); nested modules fold into their first segment. +/// +public sealed record BindingGroup( + string Key, + IReadOnlyList Members, + IReadOnlyList NestedModules, + string? InferredObject, + bool IsObjectMixed, + BindingConnectionState ConnectionState, + int? CommonConnectionId, + int UnboundCount) +{ + public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey; +} + +/// +/// Pure grouping / prefix-inference / bulk-apply logic behind the Configure page's +/// Connection Bindings panel (design 2026-08-20). No Blazor dependencies so it is +/// unit-testable without rendering the page. +/// +public static class BindingGroupBuilder +{ + public const string InstanceGroupKey = "(instance)"; + + public static IReadOnlyList Build(IEnumerable rows) + { + var byKey = new Dictionary>(StringComparer.Ordinal); + var nested = new Dictionary>(StringComparer.Ordinal); + + foreach (var row in rows.OrderBy(r => r.CanonicalName, StringComparer.Ordinal)) + { + var dot = row.CanonicalName.IndexOf('.'); + string key, tail; + if (dot < 0) { key = InstanceGroupKey; tail = row.CanonicalName; } + else + { + key = row.CanonicalName[..dot]; + tail = row.CanonicalName[(dot + 1)..]; + var tailDot = tail.LastIndexOf('.'); + if (tailDot > 0) + { + if (!nested.TryGetValue(key, out var set)) nested[key] = set = new SortedSet(StringComparer.Ordinal); + set.Add(tail[..tailDot]); + } + } + if (!byKey.TryGetValue(key, out var list)) byKey[key] = list = new List(); + list.Add(new BindingGroupMember(row, tail)); + } + + var ordered = byKey.Keys + .OrderBy(k => k == InstanceGroupKey ? 0 : 1) + .ThenBy(k => k, StringComparer.Ordinal); + + var result = new List(); + foreach (var key in ordered) + { + var members = byKey[key]; + var (obj, mixed) = InferObject(members); + var conns = members.Select(m => m.Row.ConnectionId is > 0 ? m.Row.ConnectionId : null).Distinct().ToList(); + var state = conns.Count == 1 && conns[0] is not null ? BindingConnectionState.Single + : conns.All(c => c is null) ? BindingConnectionState.None + : BindingConnectionState.Mixed; + result.Add(new BindingGroup( + key, members, + nested.TryGetValue(key, out var n) ? n.ToList() : [], + obj, mixed, state, + state == BindingConnectionState.Single ? conns[0] : null, + members.Count(m => m.IsUnbound))); + } + return result; + } + + /// + /// Longest object prefix shared by every member that has an override: each override + /// must be {prefix}.{memberTail}. Returns (null,false) when no member has an + /// override; (null,true) when members disagree or an override doesn't end in its tail. + /// + private static (string? Object, bool Mixed) InferObject(IReadOnlyList members) + { + string? candidate = null; + var any = false; + foreach (var m in members) + { + if (string.IsNullOrWhiteSpace(m.Row.Override)) continue; + any = true; + var suffix = "." + m.MemberTail; + if (!m.Row.Override.EndsWith(suffix, StringComparison.Ordinal)) return (null, true); + var prefix = m.Row.Override[..^suffix.Length]; + if (prefix.Length == 0) return (null, true); + if (candidate is null) candidate = prefix; + else if (!string.Equals(candidate, prefix, StringComparison.Ordinal)) return (null, true); + } + return any ? (candidate, false) : (null, false); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs new file mode 100644 index 00000000..21b969a4 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs @@ -0,0 +1,114 @@ +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); + } +}