fix(centralui): grouped bindings review fixes — Apply with the header at none/mixed keeps each member's connection (never unbinds; upsert Save could not persist it), object inference runs over effective references (bare-default members make an overridden group mixed, default-only prefixes infer), bindings to a deleted connection count as unbound, 'n changed · unsaved' diffs against last-saved state; bUnit behaviour tests for fallback banner, grouping and mixed-group Apply+Save
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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 `<object>.<memberTail>` 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 `<object>.<memberTail>` 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.
|
||||
|
||||
+31
-14
@@ -177,7 +177,7 @@
|
||||
{
|
||||
<span class="badge text-bg-secondary" data-test="binding-group-mixed">mixed</span>
|
||||
}
|
||||
@if (_groupChangedCounts.TryGetValue(g.Key, out var changed) && changed > 0)
|
||||
@if (GroupChangedCount(g) is var changed && changed > 0)
|
||||
{
|
||||
<span class="badge border text-muted">@changed changed · unsaved</span>
|
||||
}
|
||||
@@ -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<TemplateAttribute> 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));
|
||||
|
||||
/// <summary>Re-projects the current selections/overrides into the rows, then regroups.</summary>
|
||||
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());
|
||||
}
|
||||
|
||||
/// <summary>Per-row (connection, override) as last loaded/saved — the baseline for the "n changed · unsaved" pill.</summary>
|
||||
private Dictionary<string, (int? Conn, string? Override)> _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);
|
||||
|
||||
/// <summary>Members whose current state differs from the last loaded/saved state (per-row edits and group Applies alike).</summary>
|
||||
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<string> _expandedGroups = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string?> _groupObjectInput = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int?> _groupConnectionInput = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, int> _groupChangedCounts = new(StringComparer.Ordinal);
|
||||
private bool _showOnlyUnbound;
|
||||
|
||||
private void ToggleGroup(string key) { if (!_expandedGroups.Remove(key)) _expandedGroups.Add(key); }
|
||||
@@ -1040,26 +1062,21 @@
|
||||
/// <summary>
|
||||
/// Per-module bulk apply: rewrites every member of the group to
|
||||
/// <c>{object}.{memberTail}</c> 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.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
@@ -44,8 +44,20 @@ public static class BindingGroupBuilder
|
||||
{
|
||||
public const string InstanceGroupKey = "(instance)";
|
||||
|
||||
public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows)
|
||||
/// <param name="rows">The data-sourced flattened attributes with their current binding state.</param>
|
||||
/// <param name="knownConnectionIds">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public static IReadOnlyList<BindingGroup> Build(IEnumerable<BindingRow> rows, IReadOnlyCollection<int>? knownConnectionIds = null)
|
||||
{
|
||||
if (knownConnectionIds is not null)
|
||||
{
|
||||
var known = knownConnectionIds as ISet<int> ?? new HashSet<int>(knownConnectionIds);
|
||||
rows = rows.Select(r => r.ConnectionId is int id && !known.Contains(id) ? r with { ConnectionId = null } : r);
|
||||
}
|
||||
|
||||
var byKey = new Dictionary<string, List<BindingGroupMember>>(StringComparer.Ordinal);
|
||||
var nested = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||
|
||||
@@ -93,26 +105,40 @@ public static class BindingGroupBuilder
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Object prefix shared by every member's EFFECTIVE reference (override if set, else
|
||||
/// the default): each must be <c>{prefix}.{memberTail}</c>.
|
||||
/// <list type="bullet">
|
||||
/// <item>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".</item>
|
||||
/// <item>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.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
private static (string? Object, bool Mixed) InferObject(IReadOnlyList<BindingGroupMember> 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);
|
||||
}
|
||||
|
||||
/// <summary>Returns <c>prefix</c> when <paramref name="reference"/> is <c>{prefix}.{tail}</c>, else null.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Result of a bulk apply for one member.</summary>
|
||||
@@ -121,14 +147,19 @@ public static class BindingGroupBuilder
|
||||
/// <summary>
|
||||
/// Rewrites every member of <paramref name="group"/> to
|
||||
/// <c>{objectPath}.{memberTail}</c> on <paramref name="connectionId"/>. A blank
|
||||
/// <paramref name="objectPath"/> clears the overrides (template default applies) and
|
||||
/// leaves the connection as given. Surrounding whitespace and a trailing '.' are tolerated.
|
||||
/// <paramref name="objectPath"/> clears the overrides (template default applies).
|
||||
/// A null <paramref name="connectionId"/> (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.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<BindingChange> 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();
|
||||
}
|
||||
|
||||
+178
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class InstanceConfigureBindingBehaviourTests : BunitContext
|
||||
{
|
||||
private readonly ITemplateEngineRepository _templateRepo = Substitute.For<ITemplateEngineRepository>();
|
||||
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
|
||||
private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
|
||||
public InstanceConfigureBindingBehaviourTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
Services.AddSingleton(_templateRepo);
|
||||
Services.AddSingleton(_siteRepo);
|
||||
Services.AddSingleton(new InstanceService(_templateRepo, Substitute.For<IAuditService>()));
|
||||
Services.AddSingleton(_pipeline);
|
||||
Services.AddSingleton(Substitute.For<IBrowseService>());
|
||||
Services.AddSingleton(Substitute.For<IBindingTester>());
|
||||
|
||||
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<AuthenticationStateProvider>(authProvider);
|
||||
Services.AddSingleton(new SiteScopeService(authProvider));
|
||||
Services.AddAuthorizationCore();
|
||||
AuthorizationPolicies.AddScadaBridgeAuthorization(Services);
|
||||
}
|
||||
|
||||
private void SeedInstance(IReadOnlyList<InstanceConnectionBinding> 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<CancellationToken>()).Returns(instance);
|
||||
_templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(new Template("Reactor") { Id = 1 });
|
||||
_siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site> { new("Plant A", "plant-a") { Id = 1 } });
|
||||
_templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>()).Returns(new List<Area>());
|
||||
// Direct attributes only — the composed members are NOT here; they only exist in the flattened config.
|
||||
_templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<TemplateAttribute> { new("ChlorineFlow") { Id = 1, DataSourceReference = "ChlorineFlow" } });
|
||||
_siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DataConnection> { new("GW-1", "MxGateway", 1) { Id = 1 }, new("GW-2", "MxGateway", 1) { Id = 2 } });
|
||||
_templateRepo.GetBindingsByInstanceIdAsync(42, Arg.Any<CancellationToken>()).Returns(bindings);
|
||||
_templateRepo.GetOverridesByInstanceIdAsync(42, Arg.Any<CancellationToken>()).Returns(new List<InstanceAttributeOverride>());
|
||||
_templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any<CancellationToken>()).Returns(new List<TemplateAlarm>());
|
||||
_templateRepo.GetAlarmOverridesByInstanceIdAsync(42, Arg.Any<CancellationToken>()).Returns(new List<InstanceAlarmOverride>());
|
||||
}
|
||||
|
||||
private static Result<FlatteningPipelineResult> Flattened(params ResolvedAttribute[] attrs) =>
|
||||
Result<FlatteningPipelineResult>.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<InstanceConnectionBinding>());
|
||||
_pipeline.FlattenAndValidateAsync(42, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession>())
|
||||
.Returns(Flattened(
|
||||
DataSourced("ChlorineFlow", "ChlorineFlow"),
|
||||
DataSourced("LeftMESReceiver.MoveInFlag", "MoveInFlag"),
|
||||
DataSourced("LeftMESReceiver.ScanStateCmd", "ScanStateCmd"),
|
||||
DataSourced("RightMESReceiver.MoveInFlag", "MoveInFlag")));
|
||||
|
||||
var cut = Render<InstanceConfigurePage>(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<InstanceConnectionBinding>());
|
||||
_pipeline.FlattenAndValidateAsync(42, Arg.Any<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Failure("Template chain is empty."));
|
||||
|
||||
var cut = Render<InstanceConfigurePage>(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<InstanceConnectionBinding>
|
||||
{
|
||||
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<CancellationToken>(), Arg.Any<bool>(), Arg.Any<FlattenSession>())
|
||||
.Returns(Flattened(
|
||||
DataSourced("LeftMESReceiver.MoveInFlag", "MoveInFlag", 1, "Old.MoveInFlag"),
|
||||
DataSourced("LeftMESReceiver.ScanStateCmd", "ScanStateCmd", 2, "Old.ScanStateCmd")));
|
||||
|
||||
var cut = Render<InstanceConfigurePage>(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<CancellationToken>()).Returns(existing);
|
||||
await cut.InvokeAsync(() => cut.FindAll("button").Single(b => b.TextContent.Trim() == "Save Bindings").Click());
|
||||
|
||||
await _templateRepo.Received(1).UpdateInstanceConnectionBindingAsync(
|
||||
Arg.Is<InstanceConnectionBinding>(b => b.AttributeName == "LeftMESReceiver.MoveInFlag" && b.DataConnectionId == 1 && b.DataSourceReferenceOverride == "MESReceiver_022.MoveInFlag"),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _templateRepo.Received(1).UpdateInstanceConnectionBindingAsync(
|
||||
Arg.Is<InstanceConnectionBinding>(b => b.AttributeName == "LeftMESReceiver.ScanStateCmd" && b.DataConnectionId == 2 && b.DataSourceReferenceOverride == "MESReceiver_022.ScanStateCmd"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -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 "<prefix>.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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user