fix(transport): warn on import of instance overrides targeting locked template members — inert rows no longer silent (plan R2-05 T7)
This commit is contained in:
+166
@@ -1222,4 +1222,170 @@ public sealed class SiteInstanceImportTests : IDisposable
|
||||
|
||||
Assert.Equal(1, result.Added);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// N4 (plan R2-05 T7): warn on overrides of LOCKED template members
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Import_OverrideOnLockedAttribute_EmitsWarning_ImportStillSucceeds()
|
||||
{
|
||||
// Template attribute "SetPoint" IsLocked=true; instance carries an
|
||||
// InstanceAttributeOverride for "SetPoint" — the flattener drops it, so the
|
||||
// import surfaces an advisory warning but the row IS still written.
|
||||
await SeedLockedMemberClosureAsync(lockAttribute: true);
|
||||
|
||||
var sessionId = await ExportAllSitesAndLoadAsync();
|
||||
await WipeSiteClosureAsync();
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var nameMap = new BundleNameMap(
|
||||
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
||||
Connections: Array.Empty<ConnectionMapping>());
|
||||
|
||||
var result = await ApplyAsync(
|
||||
sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||
},
|
||||
nameMap);
|
||||
|
||||
Assert.Contains(result.Warnings, w =>
|
||||
w.Contains("SetPoint", StringComparison.Ordinal)
|
||||
&& w.Contains("locked", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// Behaviour unchanged: the override row IS still written (bundle fidelity).
|
||||
await using (var verify = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = verify.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
Assert.True(await ctx.InstanceAttributeOverrides.AnyAsync(o => o.AttributeName == "SetPoint"));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Preview_OverrideOnLockedNativeAlarmSource_ShowsWarningRow()
|
||||
{
|
||||
// Template native-alarm-source "NativeSrc" IsLocked=true; instance carries a
|
||||
// NativeAlarmSourceOverride for it. The wizard shows a non-blocking Warning row.
|
||||
await SeedLockedMemberClosureAsync(lockNativeSource: true);
|
||||
|
||||
var sessionId = await ExportAllSitesAndLoadAsync();
|
||||
|
||||
ImportPreview preview;
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var importer = scope.ServiceProvider.GetRequiredService<IBundleImporter>();
|
||||
preview = await importer.PreviewAsync(sessionId);
|
||||
}
|
||||
|
||||
Assert.Contains(preview.Items, b =>
|
||||
b.Kind == ConflictKind.Warning && b.EntityType == "Instance"
|
||||
&& b.BlockerReason!.Contains("locked", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_OverrideOnUnlockedAttribute_NoLockWarning()
|
||||
{
|
||||
// Same shape, but "SetPoint" is NOT locked — no lock warning must appear.
|
||||
await SeedLockedMemberClosureAsync(lockAttribute: false);
|
||||
|
||||
var sessionId = await ExportAllSitesAndLoadAsync();
|
||||
await WipeSiteClosureAsync();
|
||||
await using (var scope = _provider.CreateAsyncScope())
|
||||
{
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
ctx.Templates.Add(new Template("Pump") { Description = "pump tpl" });
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var nameMap = new BundleNameMap(
|
||||
Sites: new[] { new SiteMapping("plant-1", MappingAction.CreateNew, null) },
|
||||
Connections: Array.Empty<ConnectionMapping>());
|
||||
|
||||
var result = await ApplyAsync(
|
||||
sessionId,
|
||||
new List<ImportResolution>
|
||||
{
|
||||
new("Template", "Pump", ResolutionAction.Skip, null),
|
||||
new("Site", "plant-1", ResolutionAction.Add, null),
|
||||
new("Instance", "Pump-01", ResolutionAction.Add, null),
|
||||
},
|
||||
nameMap);
|
||||
|
||||
Assert.DoesNotContain(result.Warnings, w =>
|
||||
w.Contains("locked", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds a template ("Pump") + site ("plant-1") + instance ("Pump-01") whose
|
||||
/// overrides target the given members; <paramref name="lockAttribute"/> /
|
||||
/// <paramref name="lockNativeSource"/> toggle the template member's IsLocked flag.
|
||||
/// The attribute override is always present; the native-source override (and its
|
||||
/// connection) are seeded only when a native source is involved.
|
||||
/// </summary>
|
||||
private async Task SeedLockedMemberClosureAsync(bool lockAttribute = false, bool lockNativeSource = false)
|
||||
{
|
||||
await using var scope = _provider.CreateAsyncScope();
|
||||
var ctx = scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>();
|
||||
|
||||
var template = new Template("Pump") { Description = "pump tpl" };
|
||||
template.Attributes.Add(new TemplateAttribute("SetPoint") { Value = "0", IsLocked = lockAttribute });
|
||||
if (lockNativeSource)
|
||||
{
|
||||
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("NativeSrc")
|
||||
{
|
||||
ConnectionName = "OpcUaPrimary",
|
||||
SourceReference = "ns=3;s=Pump.Alarm",
|
||||
IsLocked = true,
|
||||
});
|
||||
}
|
||||
ctx.Templates.Add(template);
|
||||
|
||||
var site = new Site("Plant 1", "plant-1")
|
||||
{
|
||||
NodeAAddress = "akka://site@10.0.0.1:2552",
|
||||
NodeBAddress = "akka://site@10.0.0.2:2552",
|
||||
GrpcNodeAAddress = "10.0.0.1:8083",
|
||||
GrpcNodeBAddress = "10.0.0.2:8083",
|
||||
};
|
||||
ctx.Sites.Add(site);
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
DataConnection? conn = null;
|
||||
if (lockNativeSource)
|
||||
{
|
||||
conn = new DataConnection("OpcUaPrimary", "OpcUa", site.Id)
|
||||
{
|
||||
PrimaryConfiguration = "{\"endpoint\":\"opc.tcp://primary\"}",
|
||||
FailoverRetryCount = 5,
|
||||
};
|
||||
ctx.DataConnections.Add(conn);
|
||||
}
|
||||
|
||||
var instance = new Instance("Pump-01")
|
||||
{
|
||||
TemplateId = template.Id,
|
||||
SiteId = site.Id,
|
||||
State = InstanceState.Enabled,
|
||||
};
|
||||
instance.AttributeOverrides.Add(new InstanceAttributeOverride("SetPoint") { OverrideValue = "42" });
|
||||
if (lockNativeSource)
|
||||
{
|
||||
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("NativeSrc")
|
||||
{
|
||||
ConnectionNameOverride = "OpcUaPrimary",
|
||||
SourceReferenceOverride = "ns=3;s=Pump.Alarm2",
|
||||
});
|
||||
}
|
||||
ctx.Instances.Add(instance);
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user