diff --git a/docs/plans/2026-08-20-grouped-connection-bindings.md.tasks.json b/docs/plans/2026-08-20-grouped-connection-bindings.md.tasks.json index 88f6aca0..bdd50962 100644 --- a/docs/plans/2026-08-20-grouped-connection-bindings.md.tasks.json +++ b/docs/plans/2026-08-20-grouped-connection-bindings.md.tasks.json @@ -1,15 +1,77 @@ { "planPath": "docs/plans/2026-08-20-grouped-connection-bindings.md", "tasks": [ - {"id": 1, "subject": "Task 1: ResolvedAttribute.DefaultDataSourceReference (TemplateEngine)", "status": "pending"}, - {"id": 2, "subject": "Task 2: BindingGroupBuilder — grouping + object-prefix inference", "status": "pending"}, - {"id": 3, "subject": "Task 3: BindingGroupBuilder.ApplyObject", "status": "pending", "blockedBy": [2]}, - {"id": 4, "subject": "Task 4: Configure page — source binding rows from the flattened config", "status": "pending", "blockedBy": [1, 3]}, - {"id": 5, "subject": "Task 5: Configure page — grouped markup with per-module Object apply", "status": "pending", "blockedBy": [4]}, - {"id": 6, "subject": "Task 6: Live check on the docker rig", "status": "pending", "blockedBy": [5]}, - {"id": 7, "subject": "Task 7: CLI instance bind-module", "status": "pending"}, - {"id": 8, "subject": "Task 8: Docs", "status": "pending", "blockedBy": [5]}, - {"id": 9, "subject": "Task 9: Solution build + impacted suites", "status": "pending", "blockedBy": [1,2,3,4,5,6,7,8]} + { + "id": 1, + "subject": "Task 1: ResolvedAttribute.DefaultDataSourceReference (TemplateEngine)", + "status": "completed" + }, + { + "id": 2, + "subject": "Task 2: BindingGroupBuilder \u2014 grouping + object-prefix inference", + "status": "completed" + }, + { + "id": 3, + "subject": "Task 3: BindingGroupBuilder.ApplyObject", + "status": "completed", + "blockedBy": [ + 2 + ] + }, + { + "id": 4, + "subject": "Task 4: Configure page \u2014 source binding rows from the flattened config", + "status": "completed", + "blockedBy": [ + 1, + 3 + ] + }, + { + "id": 5, + "subject": "Task 5: Configure page \u2014 grouped markup with per-module Object apply", + "status": "completed", + "blockedBy": [ + 4 + ] + }, + { + "id": 6, + "subject": "Task 6: Live check on the docker rig", + "status": "pending", + "blockedBy": [ + 5 + ] + }, + { + "id": 7, + "subject": "Task 7: CLI instance bind-module", + "status": "completed" + }, + { + "id": 8, + "subject": "Task 8: Docs", + "status": "pending", + "blockedBy": [ + 5 + ] + }, + { + "id": 9, + "subject": "Task 9: Solution build + impacted suites", + "status": "pending", + "blockedBy": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ] + } ], - "lastUpdated": "2026-08-20T19:45:00Z" -} + "lastUpdated": "2026-08-20T21:30:00Z" +} \ No newline at end of file diff --git a/docs/requirements/Component-CentralUI.md b/docs/requirements/Component-CentralUI.md index a7b0a9f7..0b59fe8e 100644 --- a/docs/requirements/Component-CentralUI.md +++ b/docs/requirements/Component-CentralUI.md @@ -121,7 +121,7 @@ Central cluster only. Sites have no user interface. - Assign instances to areas. - Bind data connections — **per-attribute binding** where each attribute with a data source reference individually selects its data connection from the site's available connections. **The binding rows come from the instance's flattened configuration** (`IFlatteningPipeline.FlattenAndValidateAsync(id, validateScripts:false)`), so every data-sourced attribute the deploy gate will check appears — composed feature-module members (`LeftMESReceiver.MoveInFlag`, `LeftReactorSide.LeakTest.DeltaVac`, …) included. (Until 2026-08-20 the panel was built from the template's *direct* attributes only, which left 264 of a composed QA instance's 280 bindings invisible and made the instance undeployable from the UI; design `docs/plans/2026-08-20-grouped-connection-bindings-design.md`.) Only a genuine flatten *failure* falls back to the direct-attribute list, with a visible warning banner; a validation failure (e.g. unbound attributes — the very thing the panel exists to fix) is not a reason to fall back. - **Grouped by module.** Rows are grouped by the first segment of the canonical name — `(instance)` for direct attributes first, then one collapsible group per composed module in name order; nested modules fold into their first segment (`LeftReactorSide` shows *incl. LeakTest*) and members keep their nested tail (`LeakTest.DeltaVac`). The pure grouping/inference/apply logic is `BindingGroupBuilder` (CentralUI `Services/`), unit-tested without rendering the page. - - **Group header = connection + Object + Apply.** Each group header carries a connection select and an **Object** input; **Apply to n** rewrites every member of the group to `.` on that connection (a blank Object clears the overrides so the template default applies; surrounding whitespace and a trailing `.` are tolerated). The Object input is pre-filled with the **inferred** shared prefix when every overridden member agrees (`MESReceiver_022.MoveInFlag` + `MESReceiver_022.ScanStateCmd` ⇒ `MESReceiver_022`; nested object paths such as `Left_002.DelmiaReceiver` infer too); when members disagree, or an override does not end in its member tail, the group shows a **mixed** pill and the input's placeholder invites an object to unify them. Apply edits the in-memory maps only — **Save Bindings** persists every row (hidden rows included) through the existing `SetConnectionBindings` upsert, and the header shows an `n changed · unsaved` pill until then. + - **Group header = connection + Object + Apply.** Each group header carries a connection select and an **Object** input; **Apply to n** rewrites every member of the group to `.` on that connection (a blank Object clears the overrides so the template default applies; surrounding whitespace and a trailing `.` are tolerated). A header connection left at `— none —`/`— mixed —` **keeps each member's current connection** — Apply never unbinds a group (the Save path is an upsert, so an unbinding could not persist anyway). The Object input is pre-filled with the **inferred** shared prefix over every member's *effective* reference (override if set, else the default): `MESReceiver_022.MoveInFlag` + `MESReceiver_022.ScanStateCmd` ⇒ `MESReceiver_022`; nested object paths such as `Left_002.DelmiaReceiver` infer too; a direct-attribute group whose defaults already carry a shared prefix infers it with no overrides at all. Once any member has an override, the group shows a **mixed** pill — and the input's placeholder invites an object to unify it — when members disagree, an override does not end in its member tail, *or a member still sits on a bare default the deploy gate would reject*; a group with no overrides and bare defaults is merely "unset", not mixed. A binding to a connection that no longer exists at the site counts as **unbound** (it renders as `— none —` and the gate rejects it). Apply edits the in-memory maps only — **Save Bindings** persists every row (hidden rows included) through the existing `SetConnectionBindings` upsert; until then the header shows an `n changed · unsaved` pill counting members whose (connection, override) differs from the last loaded/saved state, so per-row edits and group Applies both surface. - **Pills and filters.** The card header totals unbound rows and mixed groups; each group shows `n unbound` (red) or `bound`, plus `mixed`; unbound rows render highlighted. A **Show only unbound** filter hides bound rows and fully-bound groups; **Expand all** opens every group. The legacy all-groups connection select (**Assign all to…**) is retained. A composed member whose template default is the bare member name (`MoveInFlag`) carries a ⚠ marker — such a default cannot be a real address and the deploy gate will reject the instance until an override (or a group Object) is set. - **Template default** column shows `ResolvedAttribute.DefaultDataSourceReference` — the reference as resolved from the template chain *before* the per-instance override — so the operator always sees both the default and the effective override. **Test Bindings** (button label carries the testable-row count) reads every bound row with an effective path, composed rows included. - **Override** — optional per-attribute OPC UA node id (or other protocol address). When set, replaces the template's `DataSourceReference` at flattening time; when blank, the template default is used. The greyed placeholder shows the template default for context. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor index 6c800325..5cdcf959 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor @@ -177,7 +177,7 @@ { mixed } - @if (_groupChangedCounts.TryGetValue(g.Key, out var changed) && changed > 0) + @if (GroupChangedCount(g) is var changed && changed > 0) { @changed changed · unsaved } @@ -981,7 +981,9 @@ { _flattenedForBindings = flat.Value.Configuration; source = flat.Value.Configuration.Attributes - .Where(a => !string.IsNullOrEmpty(a.DefaultDataSourceReference) || !string.IsNullOrEmpty(a.DataSourceReference)) + // Same predicate as the deploy gate (ValidateConnectionBindingCompleteness: + // DataSourceReference != null), so every row the gate will check has a row here. + .Where(a => a.DefaultDataSourceReference is not null || a.DataSourceReference is not null) .Select(a => (a.CanonicalName, a.DefaultDataSourceReference ?? a.DataSourceReference)); } } @@ -998,10 +1000,11 @@ return new BindingRow(s.Name, s.Default, null, null); }).ToList(); RebuildBindingGroups(); + SnapshotSavedBindingState(); } private static IEnumerable<(string Name, string? Default)> DirectAttributeRows(IReadOnlyList directAttrs) - => directAttrs.Where(a => !string.IsNullOrEmpty(a.DataSourceReference)).Select(a => (a.Name, a.DataSourceReference)); + => directAttrs.Where(a => a.DataSourceReference is not null).Select(a => (a.Name, a.DataSourceReference)); /// Re-projects the current selections/overrides into the rows, then regroups. private void RebuildBindingGroups() @@ -1013,9 +1016,29 @@ Override = _bindingOverrides.GetValueOrDefault(r.CanonicalName) }) .ToList(); - _bindingGroups = BindingGroupBuilder.Build(_bindingRows); + // A binding to a connection that no longer exists at the site renders as + // "— none —" and the deploy gate rejects it — count it as unbound. + _bindingGroups = BindingGroupBuilder.Build(_bindingRows, _siteConnections.Select(c => c.Id).ToHashSet()); } + /// Per-row (connection, override) as last loaded/saved — the baseline for the "n changed · unsaved" pill. + private Dictionary _savedBindingState = new(StringComparer.Ordinal); + + private void SnapshotSavedBindingState() + => _savedBindingState = _bindingRows.ToDictionary( + r => r.CanonicalName, + r => (_bindingSelections.TryGetValue(r.CanonicalName, out var c) ? c : (int?)null, _bindingOverrides.GetValueOrDefault(r.CanonicalName)), + StringComparer.Ordinal); + + /// Members whose current state differs from the last loaded/saved state (per-row edits and group Applies alike). + private int GroupChangedCount(BindingGroup g) + => g.Members.Count(m => + { + var name = m.Row.CanonicalName; + var current = (_bindingSelections.TryGetValue(name, out var c) ? c : (int?)null, _bindingOverrides.GetValueOrDefault(name)); + return !_savedBindingState.TryGetValue(name, out var saved) || saved != current; + }); + private int TotalUnbound => _bindingGroups.Sum(g => g.UnboundCount); private int MixedGroups => _bindingGroups.Count(g => g.IsObjectMixed || g.ConnectionState == BindingConnectionState.Mixed); @@ -1023,7 +1046,6 @@ private readonly HashSet _expandedGroups = new(StringComparer.Ordinal); private readonly Dictionary _groupObjectInput = new(StringComparer.Ordinal); private readonly Dictionary _groupConnectionInput = new(StringComparer.Ordinal); - private readonly Dictionary _groupChangedCounts = new(StringComparer.Ordinal); private bool _showOnlyUnbound; private void ToggleGroup(string key) { if (!_expandedGroups.Remove(key)) _expandedGroups.Add(key); } @@ -1040,26 +1062,21 @@ /// /// Per-module bulk apply: rewrites every member of the group to /// {object}.{memberTail} on the header's connection (blank object clears - /// the overrides). Edits only the in-memory selection/override maps — Save - /// Bindings persists them — and counts rows that actually changed for the - /// "n changed · unsaved" pill. + /// the overrides; a header left at "— none —"/"— mixed —" keeps each member's + /// current connection — Apply never unbinds a group). Edits only the in-memory + /// selection/override maps; Save Bindings persists them. /// private void ApplyGroupObject(BindingGroup g) { var connId = GroupConnectionInput(g); var changes = BindingGroupBuilder.ApplyObject(g, connId > 0 ? connId : null, GroupObjectInput(g)); - var changed = 0; foreach (var c in changes) { - var before = (_bindingSelections.GetValueOrDefault(c.CanonicalName), _bindingOverrides.GetValueOrDefault(c.CanonicalName)); if (c.ConnectionId is > 0) _bindingSelections[c.CanonicalName] = c.ConnectionId.Value; else _bindingSelections.Remove(c.CanonicalName); if (!string.IsNullOrEmpty(c.Override)) _bindingOverrides[c.CanonicalName] = c.Override; else _bindingOverrides.Remove(c.CanonicalName); - if (before != (_bindingSelections.GetValueOrDefault(c.CanonicalName), _bindingOverrides.GetValueOrDefault(c.CanonicalName))) - changed++; } - _groupChangedCounts[g.Key] = _groupChangedCounts.GetValueOrDefault(g.Key) + changed; _groupObjectInput.Remove(g.Key); _groupConnectionInput.Remove(g.Key); _expandedGroups.Add(g.Key); @@ -1199,7 +1216,7 @@ var result = await InstanceService.SetConnectionBindingsAsync(Id, bindings, user); if (result.IsSuccess) { - _groupChangedCounts.Clear(); + SnapshotSavedBindingState(); _toast.ShowSuccess($"Saved {bindings.Count} connection binding(s)."); } else diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs index e801b162..5636447d 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Services/BindingGroupBuilder.cs @@ -44,8 +44,20 @@ public static class BindingGroupBuilder { public const string InstanceGroupKey = "(instance)"; - public static IReadOnlyList Build(IEnumerable rows) + /// The data-sourced flattened attributes with their current binding state. + /// + /// The site's data connection ids. When supplied, a row whose connection id is not + /// in the set is treated as UNBOUND — a binding to a deleted connection renders as + /// "— none —" and the deploy gate rejects it, so the pills must agree. + /// + public static IReadOnlyList Build(IEnumerable rows, IReadOnlyCollection? knownConnectionIds = null) { + if (knownConnectionIds is not null) + { + var known = knownConnectionIds as ISet ?? new HashSet(knownConnectionIds); + rows = rows.Select(r => r.ConnectionId is int id && !known.Contains(id) ? r with { ConnectionId = null } : r); + } + var byKey = new Dictionary>(StringComparer.Ordinal); var nested = new Dictionary>(StringComparer.Ordinal); @@ -93,26 +105,40 @@ public static class BindingGroupBuilder } /// - /// 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. + /// Object prefix shared by every member's EFFECTIVE reference (override if set, else + /// the default): each must be {prefix}.{memberTail}. + /// + /// No member has an override: infer from the defaults alone — (prefix,false) when + /// they all agree, otherwise (null,false). Nothing has been edited yet, so a bare + /// composed default is "unset", not "mixed". + /// Some member has an override: (prefix,false) only when EVERY member's effective + /// reference shares it; (null,true) when members disagree, an override doesn't end in + /// its tail, or a member still sits on a bare default the gate would reject. + /// /// private static (string? Object, bool Mixed) InferObject(IReadOnlyList members) { + var anyOverride = members.Any(m => !string.IsNullOrWhiteSpace(m.Row.Override)); 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); + var effective = m.EffectiveReference; + var prefix = PrefixOf(effective, m.MemberTail); + if (prefix is null || (candidate is not null && !string.Equals(candidate, prefix, StringComparison.Ordinal))) + return (null, anyOverride); + candidate = prefix; } - return any ? (candidate, false) : (null, false); + return (candidate, false); + } + + /// Returns prefix when is {prefix}.{tail}, else null. + private static string? PrefixOf(string? reference, string tail) + { + if (string.IsNullOrWhiteSpace(reference)) return null; + var suffix = "." + tail; + if (!reference.EndsWith(suffix, StringComparison.Ordinal)) return null; + var prefix = reference[..^suffix.Length]; + return prefix.Length == 0 ? null : prefix; } /// Result of a bulk apply for one member. @@ -121,14 +147,19 @@ public static class BindingGroupBuilder /// /// Rewrites every member of to /// {objectPath}.{memberTail} on . A blank - /// clears the overrides (template default applies) and - /// leaves the connection as given. Surrounding whitespace and a trailing '.' are tolerated. + /// clears the overrides (template default applies). + /// A null (header reading "— mixed —"/"— none —") + /// KEEPS each member's current connection — it never unbinds a group, since the + /// Save path is an upsert and a removal could not persist anyway. Surrounding + /// whitespace and a trailing '.' are tolerated. /// public static IReadOnlyList ApplyObject(BindingGroup group, int? connectionId, string? objectPath) { var obj = (objectPath ?? string.Empty).Trim().TrimEnd('.'); return group.Members - .Select(m => new BindingChange(m.Row.CanonicalName, connectionId, + .Select(m => new BindingChange( + m.Row.CanonicalName, + connectionId ?? (m.Row.ConnectionId is > 0 ? m.Row.ConnectionId : null), obj.Length == 0 ? null : obj + "." + m.MemberTail)) .ToList(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingBehaviourTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingBehaviourTests.cs new file mode 100644 index 00000000..9486d6ea --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/InstanceConfigureBindingBehaviourTests.cs @@ -0,0 +1,178 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; +using ZB.MOM.WW.ScadaBridge.CentralUI.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; +using ZB.MOM.WW.ScadaBridge.Commons.Types; +using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening; +using ZB.MOM.WW.ScadaBridge.DeploymentManager; +using ZB.MOM.WW.ScadaBridge.Security; +using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services; +using InstanceConfigurePage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.InstanceConfigure; + +namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment; + +/// +/// Behavioural (bUnit) coverage of the grouped Connection Bindings panel (design +/// 2026-08-20), complementing the structural pins: the rows come from the flattened +/// configuration, a flatten FAILURE falls back to the direct attributes with a banner, +/// a group Apply whose header connection is left at "— mixed —" keeps every member's +/// own connection (review finding: it used to unbind the group, which an upsert Save +/// could never persist), and Save posts the composed rows. +/// +public class InstanceConfigureBindingBehaviourTests : BunitContext +{ + private readonly ITemplateEngineRepository _templateRepo = Substitute.For(); + private readonly ISiteRepository _siteRepo = Substitute.For(); + private readonly IFlatteningPipeline _pipeline = Substitute.For(); + + public InstanceConfigureBindingBehaviourTests() + { + JSInterop.Mode = JSRuntimeMode.Loose; + Services.AddSingleton(_templateRepo); + Services.AddSingleton(_siteRepo); + Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For())); + Services.AddSingleton(_pipeline); + Services.AddSingleton(Substitute.For()); + Services.AddSingleton(Substitute.For()); + + var claims = new[] + { + new Claim(JwtTokenService.UsernameClaimType, "deployer"), + new Claim(JwtTokenService.RoleClaimType, "Deployer"), + }; + var authProvider = new TestAuthStateProvider(new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"))); + Services.AddSingleton(authProvider); + Services.AddSingleton(new SiteScopeService(authProvider)); + Services.AddAuthorizationCore(); + AuthorizationPolicies.AddScadaBridgeAuthorization(Services); + } + + private void SeedInstance(IReadOnlyList bindings) + { + var instance = new Instance("Z28061") { Id = 42, TemplateId = 1, SiteId = 1, + State = ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.InstanceState.NotDeployed }; + _templateRepo.GetInstanceByIdAsync(42, Arg.Any()).Returns(instance); + _templateRepo.GetTemplateByIdAsync(1, Arg.Any()).Returns(new Template("Reactor") { Id = 1 }); + _siteRepo.GetAllSitesAsync(Arg.Any()).Returns(new List { new("Plant A", "plant-a") { Id = 1 } }); + _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any()).Returns(new List()); + // Direct attributes only — the composed members are NOT here; they only exist in the flattened config. + _templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any()) + .Returns(new List { new("ChlorineFlow") { Id = 1, DataSourceReference = "ChlorineFlow" } }); + _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any()) + .Returns(new List { new("GW-1", "MxGateway", 1) { Id = 1 }, new("GW-2", "MxGateway", 1) { Id = 2 } }); + _templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any()).Returns(bindings); + _templateRepo.GetOverridesByInstanceIdAsync(42, Arg.Any()).Returns(new List()); + _templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any()).Returns(new List()); + _templateRepo.GetAlarmOverridesByInstanceIdAsync(42, Arg.Any()).Returns(new List()); + } + + private static Result Flattened(params ResolvedAttribute[] attrs) => + Result.Success(new FlatteningPipelineResult( + new FlattenedConfiguration { InstanceUniqueName = "Z28061", Attributes = attrs }, + "hash", + new ValidationResult())); + + private static ResolvedAttribute DataSourced(string canonical, string dflt, int? conn = null, string? effective = null) => + new() { CanonicalName = canonical, DataType = "Boolean", DataSourceReference = effective ?? dflt, + DefaultDataSourceReference = dflt, BoundDataConnectionId = conn }; + + [Fact] + public void Rows_ComeFromFlattenedConfiguration_ComposedMembersGrouped() + { + SeedInstance(new List()); + _pipeline.FlattenAndValidateAsync(42, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Flattened( + DataSourced("ChlorineFlow", "ChlorineFlow"), + DataSourced("LeftMESReceiver.MoveInFlag", "MoveInFlag"), + DataSourced("LeftMESReceiver.ScanStateCmd", "ScanStateCmd"), + DataSourced("RightMESReceiver.MoveInFlag", "MoveInFlag"))); + + var cut = Render(p => p.Add(c => c.Id, 42)); + + cut.WaitForAssertion(() => + { + var headers = cut.FindAll("[data-test=\"binding-group-header\"]"); + Assert.Equal(3, headers.Count); + Assert.Contains("(instance)", headers[0].TextContent); + Assert.Contains("LeftMESReceiver", headers[1].TextContent); + Assert.Contains("RightMESReceiver", headers[2].TextContent); + Assert.Equal("4 unbound", cut.Find("[data-test=\"binding-total-unbound\"]").TextContent.Trim()); + Assert.Empty(cut.FindAll("[data-test=\"binding-source-warning\"]")); + }); + } + + [Fact] + public void FlattenFailure_FallsBackToDirectAttributes_WithBanner() + { + SeedInstance(new List()); + _pipeline.FlattenAndValidateAsync(42, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Failure("Template chain is empty.")); + + var cut = Render(p => p.Add(c => c.Id, 42)); + + cut.WaitForAssertion(() => + { + Assert.Contains("Template chain is empty.", cut.Find("[data-test=\"binding-source-warning\"]").TextContent); + var headers = cut.FindAll("[data-test=\"binding-group-header\"]"); + Assert.Single(headers); + Assert.Contains("(instance)", headers[0].TextContent); + }); + } + + [Fact] + public async Task ApplyOnMixedConnectionGroup_KeepsEachMembersConnection_AndSavePostsComposedRows() + { + SeedInstance(new List + { + new("LeftMESReceiver.MoveInFlag") { Id = 1, InstanceId = 42, DataConnectionId = 1, DataSourceReferenceOverride = "Old.MoveInFlag" }, + new("LeftMESReceiver.ScanStateCmd") { Id = 2, InstanceId = 42, DataConnectionId = 2, DataSourceReferenceOverride = "Old.ScanStateCmd" }, + }); + _pipeline.FlattenAndValidateAsync(42, Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Flattened( + DataSourced("LeftMESReceiver.MoveInFlag", "MoveInFlag", 1, "Old.MoveInFlag"), + DataSourced("LeftMESReceiver.ScanStateCmd", "ScanStateCmd", 2, "Old.ScanStateCmd"))); + + var cut = Render(p => p.Add(c => c.Id, 42)); + cut.WaitForAssertion(() => Assert.Single(cut.FindAll("[data-test=\"binding-group-header\"]"))); + + // Connections differ (1 vs 2) → header select reads "— mixed —" (value 0); inferred object is "Old". + var header = cut.Find("[data-test=\"binding-group-header\"]"); + Assert.Contains("— mixed —", header.QuerySelector("[data-test=\"binding-group-connection\"]")!.TextContent); + Assert.Equal("Old", header.QuerySelector("[data-test=\"binding-group-object\"]")!.GetAttribute("value")); + + await cut.InvokeAsync(() => cut.Find("[data-test=\"binding-group-object\"]").Change("MESReceiver_022")); + await cut.InvokeAsync(() => cut.Find("[data-test=\"binding-group-apply\"]").Click()); + + cut.WaitForAssertion(() => + { + // Still bound — Apply with the header at "— mixed —" must not unbind anyone. + Assert.Empty(cut.FindAll("[data-test=\"binding-group-unbound\"]")); + Assert.Contains("2 changed", cut.Find("[data-test=\"binding-group-header\"]").TextContent); + // Object unified → header pre-fills the new prefix; the group still reads + // "mixed" because the CONNECTIONS were deliberately left different (1 vs 2). + Assert.Equal("MESReceiver_022", cut.Find("[data-test=\"binding-group-object\"]").GetAttribute("value")); + Assert.Single(cut.FindAll("[data-test=\"binding-group-mixed\"]")); + Assert.Contains("— mixed —", cut.Find("[data-test=\"binding-group-connection\"]").TextContent); + }); + + // Save posts both composed rows with their ORIGINAL connections and the rewritten overrides. + var existing = await _templateRepo.GetBindingsByInstanceIdAsync(42); + _templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any()).Returns(existing); + await cut.InvokeAsync(() => cut.FindAll("button").Single(b => b.TextContent.Trim() == "Save Bindings").Click()); + + await _templateRepo.Received(1).UpdateInstanceConnectionBindingAsync( + Arg.Is(b => b.AttributeName == "LeftMESReceiver.MoveInFlag" && b.DataConnectionId == 1 && b.DataSourceReferenceOverride == "MESReceiver_022.MoveInFlag"), + Arg.Any()); + await _templateRepo.Received(1).UpdateInstanceConnectionBindingAsync( + Arg.Is(b => b.AttributeName == "LeftMESReceiver.ScanStateCmd" && b.DataConnectionId == 2 && b.DataSourceReferenceOverride == "MESReceiver_022.ScanStateCmd"), + Arg.Any()); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs index 488c4901..dfad012d 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Services/BindingGroupBuilderTests.cs @@ -142,4 +142,60 @@ public class BindingGroupBuilderTests Assert.Null(c.Override); Assert.Equal(2, c.ConnectionId); } + + // ── Review fixes 2026-08-20 ── + + [Fact] + public void ApplyObject_NullConnection_KeepsEachMembersCurrentConnection() + { + // A header reading "— mixed —"/"— none —" (0) must not unbind the group: + // Save is an upsert, so a removal could never persist anyway. + var g = Assert.Single(BindingGroupBuilder.Build(new[] + { + Row("M.A", "A", 1, "Old.A"), + Row("M.B", "B", 2, "Old.B"), + Row("M.C", "C", null, null), + })); + var changes = BindingGroupBuilder.ApplyObject(g, connectionId: null, objectPath: "New"); + Assert.Equal(new int?[] { 1, 2, null }, changes.Select(c => c.ConnectionId)); + Assert.All(changes, c => Assert.StartsWith("New.", c.Override!)); + } + + [Fact] + public void InferredObject_MixedWhenSomeMembersStillOnBareDefault() + { + // M.B has no override and its default ("B") is not ".B" — the gate + // will reject it, so the group must read mixed rather than unified on "O". + var rows = new[] { Row("M.A", "A", 1, "O.A"), Row("M.B", "B", 1, null) }; + var g = Assert.Single(BindingGroupBuilder.Build(rows)); + Assert.Null(g.InferredObject); + Assert.True(g.IsObjectMixed); + } + + [Fact] + public void InferredObject_UsesEffectiveReference_DefaultThatCarriesPrefixIsNotMixed() + { + var rows = new[] { Row("M.A", "A", 1, "O.A"), Row("M.B", "B", 1, null) with { DefaultReference = "O.B" } }; + var g = Assert.Single(BindingGroupBuilder.Build(rows)); + Assert.Equal("O", g.InferredObject); + Assert.False(g.IsObjectMixed); + } + + [Fact] + public void InferredObject_NoOverrides_InfersFromDefaultsWhenTheyShareAPrefix() + { + var rows = new[] { Row("A", "Z28061.A", 1, null), Row("B", "Z28061.B", 1, null) }; + var g = Assert.Single(BindingGroupBuilder.Build(rows)); + Assert.Equal("Z28061", g.InferredObject); + Assert.False(g.IsObjectMixed); + } + + [Fact] + public void IsUnbound_WhenConnectionIdIsNotAKnownConnection() + { + var rows = new[] { Row("M.A", "A", 99, "O.A") }; + var g = Assert.Single(BindingGroupBuilder.Build(rows, knownConnectionIds: new[] { 1, 2 })); + Assert.Equal(1, g.UnboundCount); + Assert.Equal(BindingConnectionState.None, g.ConnectionState); + } }