feat(centralui): CreateBindingPlanner — prefill/tail-strip/binding-list logic for create-time bindings
Claude-Session: https://claude.ai/code/session_01G2GHPnbbKGvyyfbr2XBRVH
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pure logic behind the Create Instance page's Connection Bindings card
|
||||
/// (design 2026-08-21): Prefill from a root object, Object inference from a
|
||||
/// browser pick, and the binding list sent with Create. Groups come from
|
||||
/// <see cref="BindingGroupBuilder.Build"/> over a template+site preview flatten.
|
||||
/// No Blazor dependencies, so it is unit-testable without rendering the page.
|
||||
/// </summary>
|
||||
public static class CreateBindingPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-group operator input. <paramref name="ConnectionId"/> 0 = none.
|
||||
/// <paramref name="Suggested"/> marks an Object that <see cref="Prefill"/> derived
|
||||
/// from the root object rather than one the operator typed or the template carried —
|
||||
/// it drives the row's provenance pill only, never the binding itself.
|
||||
/// </summary>
|
||||
public sealed record GroupInput(int ConnectionId, string? Object, bool Suggested = false);
|
||||
|
||||
/// <summary>Where a group's current Object value came from.</summary>
|
||||
public enum ObjectSource { None, Typed, Suggested, FromTemplate }
|
||||
|
||||
/// <summary>
|
||||
/// Sets <paramref name="connectionId"/> on every group and fills BLANK Objects only:
|
||||
/// the instance group gets <paramref name="rootObject"/>; a module group whose
|
||||
/// defaults already share a prefix keeps it (<see cref="BindingGroup.InferredObject"/>);
|
||||
/// otherwise, when a root is given, <c>{root}.{groupKey}</c> is suggested. A typed
|
||||
/// Object is never overwritten.
|
||||
/// </summary>
|
||||
/// <param name="groups">The groups built from the preview flatten.</param>
|
||||
/// <param name="current">The card's current per-group inputs.</param>
|
||||
/// <param name="connectionId">Data connection to apply to every group.</param>
|
||||
/// <param name="rootObject">Root object path; blank leaves bare-default groups unset.</param>
|
||||
/// <returns>The complete new input map — one entry per group.</returns>
|
||||
public static IReadOnlyDictionary<string, GroupInput> Prefill(
|
||||
IReadOnlyList<BindingGroup> groups,
|
||||
IReadOnlyDictionary<string, GroupInput> current,
|
||||
int connectionId,
|
||||
string? rootObject)
|
||||
{
|
||||
var root = (rootObject ?? string.Empty).Trim().TrimEnd('.');
|
||||
var result = new Dictionary<string, GroupInput>(StringComparer.Ordinal);
|
||||
foreach (var g in groups)
|
||||
{
|
||||
current.TryGetValue(g.Key, out var existing);
|
||||
var obj = existing?.Object;
|
||||
var suggested = false;
|
||||
if (string.IsNullOrWhiteSpace(obj))
|
||||
{
|
||||
if (g.IsInstanceGroup)
|
||||
{
|
||||
obj = root.Length == 0 ? null : root;
|
||||
}
|
||||
else if (g.InferredObject is { Length: > 0 } inferred)
|
||||
{
|
||||
obj = inferred;
|
||||
}
|
||||
else if (root.Length == 0)
|
||||
{
|
||||
obj = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
obj = root + "." + g.Key;
|
||||
suggested = true;
|
||||
}
|
||||
}
|
||||
result[g.Key] = new GroupInput(connectionId, obj, suggested);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Where a group's current Object value came from, for the row's provenance pill.</summary>
|
||||
/// <param name="group">The group the input belongs to.</param>
|
||||
/// <param name="input">The group's current input, or null when untouched.</param>
|
||||
/// <returns>The provenance of <paramref name="input"/>'s Object.</returns>
|
||||
public static ObjectSource SourceOf(BindingGroup group, GroupInput? input)
|
||||
{
|
||||
var obj = input?.Object?.Trim().TrimEnd('.');
|
||||
if (string.IsNullOrEmpty(obj)) return ObjectSource.None;
|
||||
if (!group.IsInstanceGroup && string.Equals(obj, group.InferredObject, StringComparison.Ordinal))
|
||||
return ObjectSource.FromTemplate;
|
||||
return input!.Suggested ? ObjectSource.Suggested : ObjectSource.Typed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Object for a browser pick: an Object node is used as-is; a Variable whose id ends
|
||||
/// with <c>.{memberTail}</c> of any member of the group has that tail stripped, so
|
||||
/// picking a leaf under the module still binds the whole group.
|
||||
/// </summary>
|
||||
/// <param name="group">The group whose Browse… button was used.</param>
|
||||
/// <param name="pickedNodeId">Node id the operator selected.</param>
|
||||
/// <returns>The object path to put in the group's Object input.</returns>
|
||||
public static string ObjectFromPick(BindingGroup group, string pickedNodeId)
|
||||
{
|
||||
foreach (var m in group.Members)
|
||||
{
|
||||
var suffix = "." + m.MemberTail;
|
||||
if (pickedNodeId.EndsWith(suffix, StringComparison.Ordinal) && pickedNodeId.Length > suffix.Length)
|
||||
return pickedNodeId[..^suffix.Length];
|
||||
}
|
||||
return pickedNodeId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bindings to send with Create: every member of every group that has BOTH a
|
||||
/// connection and a non-blank Object, rewritten via <see cref="BindingGroupBuilder.ApplyObject"/>.
|
||||
/// Groups missing either are left unbound for Configure.
|
||||
/// </summary>
|
||||
/// <param name="groups">The groups built from the preview flatten.</param>
|
||||
/// <param name="inputs">The card's per-group inputs.</param>
|
||||
/// <returns>The binding list for <c>SetConnectionBindingsAsync</c>.</returns>
|
||||
public static IReadOnlyList<ConnectionBinding> BuildBindings(
|
||||
IReadOnlyList<BindingGroup> groups,
|
||||
IReadOnlyDictionary<string, GroupInput> inputs)
|
||||
{
|
||||
var list = new List<ConnectionBinding>();
|
||||
foreach (var g in groups)
|
||||
{
|
||||
if (!inputs.TryGetValue(g.Key, out var input) || input.ConnectionId <= 0 || string.IsNullOrWhiteSpace(input.Object))
|
||||
continue;
|
||||
foreach (var c in BindingGroupBuilder.ApplyObject(g, input.ConnectionId, input.Object))
|
||||
list.Add(new ConnectionBinding(c.CanonicalName, c.ConnectionId!.Value, c.Override));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the pure logic behind the Create Instance page's Connection Bindings card
|
||||
/// (design 2026-08-21): Prefill from a root object, Object inference from a browser
|
||||
/// pick, and the binding list sent with Create.
|
||||
/// </summary>
|
||||
public class CreateBindingPlannerTests
|
||||
{
|
||||
private static IReadOnlyList<BindingGroup> Groups() => BindingGroupBuilder.Build(
|
||||
[
|
||||
new BindingRow("Running", "Running", null, null), // (instance)
|
||||
new BindingRow("Left.MoveInFlag", "MoveInFlag", null, null), // bare default → suggest
|
||||
new BindingRow("Left.MoveOutFlag", "MoveOutFlag", null, null),
|
||||
new BindingRow("Recv.Count", "MESReceiver_023.Count", null, null), // prefixed default → from template
|
||||
new BindingRow("Recv.State", "MESReceiver_023.State", null, null),
|
||||
]);
|
||||
|
||||
[Fact]
|
||||
public void Prefill_fills_blank_objects_only()
|
||||
{
|
||||
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput>
|
||||
{
|
||||
["Left"] = new(0, "Typed_Left"),
|
||||
};
|
||||
var result = CreateBindingPlanner.Prefill(Groups(), inputs, connectionId: 7, rootObject: "Z28063");
|
||||
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Z28063"), result[BindingGroupBuilder.InstanceGroupKey]);
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "Typed_Left"), result["Left"]); // typed wins
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]); // from template
|
||||
Assert.Equal(CreateBindingPlanner.ObjectSource.Typed, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
|
||||
Assert.Equal(CreateBindingPlanner.ObjectSource.FromTemplate, CreateBindingPlanner.SourceOf(Groups()[2], result["Recv"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefill_suggests_root_dot_module_for_bare_defaults()
|
||||
{
|
||||
var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary<string, CreateBindingPlanner.GroupInput>(), 7, "Z28063");
|
||||
Assert.Equal("Z28063.Left", result["Left"].Object);
|
||||
Assert.Equal(CreateBindingPlanner.ObjectSource.Suggested, CreateBindingPlanner.SourceOf(Groups()[1], result["Left"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Prefill_with_blank_root_sets_connection_only()
|
||||
{
|
||||
var result = CreateBindingPlanner.Prefill(Groups(), new Dictionary<string, CreateBindingPlanner.GroupInput>(), 7, "");
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result[BindingGroupBuilder.InstanceGroupKey]);
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, null), result["Left"]);
|
||||
Assert.Equal(new CreateBindingPlanner.GroupInput(7, "MESReceiver_023"), result["Recv"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Z28063.Left.MoveInFlag", "Z28063.Left")] // leaf under the group → strip tail
|
||||
[InlineData("Z28063.Left", "Z28063.Left")] // object node → as-is
|
||||
[InlineData("ns=2;s=Plant.Left.MoveOutFlag", "ns=2;s=Plant.Left")]
|
||||
[InlineData("Other.Thing", "Other.Thing")] // no member tail matches → as-is
|
||||
public void ObjectFromPick_strips_member_tail(string picked, string expected)
|
||||
=> Assert.Equal(expected, CreateBindingPlanner.ObjectFromPick(Groups()[1], picked));
|
||||
|
||||
[Fact]
|
||||
public void BuildBindings_skips_groups_without_connection_or_object()
|
||||
{
|
||||
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput>
|
||||
{
|
||||
[BindingGroupBuilder.InstanceGroupKey] = new(7, "Z28063"),
|
||||
["Left"] = new(7, null), // connection, no object → skipped (stays unbound)
|
||||
["Recv"] = new(0, "MESReceiver_023"), // object, no connection → skipped
|
||||
};
|
||||
var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
|
||||
var single = Assert.Single(bindings);
|
||||
Assert.Equal(("Running", 7, "Z28063.Running"), (single.AttributeName, single.DataConnectionId, single.DataSourceReferenceOverride));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBindings_rewrites_every_member_of_a_bound_group()
|
||||
{
|
||||
var inputs = new Dictionary<string, CreateBindingPlanner.GroupInput> { ["Left"] = new(7, "Z28063.Left") };
|
||||
var bindings = CreateBindingPlanner.BuildBindings(Groups(), inputs);
|
||||
Assert.Equal(2, bindings.Count);
|
||||
Assert.Contains(bindings, b => b.AttributeName == "Left.MoveInFlag" && b.DataSourceReferenceOverride == "Z28063.Left.MoveInFlag");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user