diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs new file mode 100644 index 00000000..c54dee0a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/CreateBindingPlanner.cs @@ -0,0 +1,129 @@ +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +/// +/// 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 +/// over a template+site preview flatten. +/// No Blazor dependencies, so it is unit-testable without rendering the page. +/// +public static class CreateBindingPlanner +{ + /// + /// Per-group operator input. 0 = none. + /// marks an Object that 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. + /// + public sealed record GroupInput(int ConnectionId, string? Object, bool Suggested = false); + + /// Where a group's current Object value came from. + public enum ObjectSource { None, Typed, Suggested, FromTemplate } + + /// + /// Sets on every group and fills BLANK Objects only: + /// the instance group gets ; a module group whose + /// defaults already share a prefix keeps it (); + /// otherwise, when a root is given, {root}.{groupKey} is suggested. A typed + /// Object is never overwritten. + /// + /// The groups built from the preview flatten. + /// The card's current per-group inputs. + /// Data connection to apply to every group. + /// Root object path; blank leaves bare-default groups unset. + /// The complete new input map — one entry per group. + public static IReadOnlyDictionary Prefill( + IReadOnlyList groups, + IReadOnlyDictionary current, + int connectionId, + string? rootObject) + { + var root = (rootObject ?? string.Empty).Trim().TrimEnd('.'); + var result = new Dictionary(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; + } + + /// Where a group's current Object value came from, for the row's provenance pill. + /// The group the input belongs to. + /// The group's current input, or null when untouched. + /// The provenance of 's Object. + 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; + } + + /// + /// Object for a browser pick: an Object node is used as-is; a Variable whose id ends + /// with .{memberTail} of any member of the group has that tail stripped, so + /// picking a leaf under the module still binds the whole group. + /// + /// The group whose Browse… button was used. + /// Node id the operator selected. + /// The object path to put in the group's Object input. + 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; + } + + /// + /// The bindings to send with Create: every member of every group that has BOTH a + /// connection and a non-blank Object, rewritten via . + /// Groups missing either are left unbound for Configure. + /// + /// The groups built from the preview flatten. + /// The card's per-group inputs. + /// The binding list for SetConnectionBindingsAsync. + public static IReadOnlyList BuildBindings( + IReadOnlyList groups, + IReadOnlyDictionary inputs) + { + var list = new List(); + 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; + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs new file mode 100644 index 00000000..8cf52a90 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/CreateBindingPlannerTests.cs @@ -0,0 +1,84 @@ +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Services; + +/// +/// 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. +/// +public class CreateBindingPlannerTests +{ + private static IReadOnlyList 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 + { + ["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(), 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(), 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 + { + [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 { ["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"); + } +}