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:
+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