feat(centralui): BindingGroupBuilder — group flattened binding rows by module, infer the shared MxAccess object prefix, count unbound

This commit is contained in:
Joseph Doherty
2026-08-20 15:53:59 -04:00
parent 3acb79d97e
commit 5da7003e88
2 changed files with 231 additions and 0 deletions
@@ -0,0 +1,117 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
/// <summary>One data-sourced flattened attribute as the Configure page sees it.</summary>
/// <param name="CanonicalName">Path-qualified flattened name, e.g. <c>LeftMESReceiver.MoveInFlag</c>.</param>
/// <param name="DefaultReference">Template/composed default reference (pre-override), may be null.</param>
/// <param name="ConnectionId">Bound data connection id, or null when unbound.</param>
/// <param name="Override">Per-instance reference override, or null when the default applies.</param>
public sealed record BindingRow(string CanonicalName, string? DefaultReference, int? ConnectionId, string? Override);
public enum BindingConnectionState { None, Single, Mixed }
/// <summary>A member of a <see cref="BindingGroup"/>: the row plus its tail relative to the group.</summary>
public sealed record BindingGroupMember(BindingRow Row, string MemberTail)
{
public bool IsUnbound => Row.ConnectionId is null or <= 0;
/// <summary>Effective reference: override if set, else the default.</summary>
public string? EffectiveReference => string.IsNullOrWhiteSpace(Row.Override) ? Row.DefaultReference : Row.Override;
}
/// <summary>
/// One module's worth of binding rows. <see cref="Key"/> is the first segment of the
/// canonical name (<see cref="BindingGroupBuilder.InstanceGroupKey"/> for direct
/// attributes); nested modules fold into their first segment.
/// </summary>
public sealed record BindingGroup(
string Key,
IReadOnlyList<BindingGroupMember> Members,
IReadOnlyList<string> NestedModules,
string? InferredObject,
bool IsObjectMixed,
BindingConnectionState ConnectionState,
int? CommonConnectionId,
int UnboundCount)
{
public bool IsInstanceGroup => Key == BindingGroupBuilder.InstanceGroupKey;
}
/// <summary>
/// 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.
/// </summary>
public static class BindingGroupBuilder
{
public const string InstanceGroupKey = "(instance)";
public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows)
{
var byKey = new Dictionary<string, List<BindingGroupMember>>(StringComparer.Ordinal);
var nested = new Dictionary<string, SortedSet<string>>(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<string>(StringComparer.Ordinal);
set.Add(tail[..tailDot]);
}
}
if (!byKey.TryGetValue(key, out var list)) byKey[key] = list = new List<BindingGroupMember>();
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<BindingGroup>();
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;
}
/// <summary>
/// Longest object prefix shared by every member that has an override: each override
/// must be <c>{prefix}.{memberTail}</c>. Returns (null,false) when no member has an
/// override; (null,true) when members disagree or an override doesn't end in its tail.
/// </summary>
private static (string? Object, bool Mixed) InferObject(IReadOnlyList<BindingGroupMember> 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);
}
}