feat(centralui): BindingGroupBuilder — group flattened binding rows by module, infer the shared MxAccess object prefix, count unbound
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user