feat(instance): native-alarm-source-override CSV bulk import (deferred #12) + doc fixes
Closes the operator parity gap the deferred-work register tracked as #12: native alarm source overrides could only be set one-at-a-time, while attribute overrides had a CSV bulk path. Adds an all-or-nothing CSV import for native sources. - Commons: extract the RFC-4180 line splitter into a shared CsvLineSplitter (refactor OverrideCsvParser onto it — no behavior change, pinned by its tests); new NativeAlarmSourceOverrideCsvParser (header SourceName,Connection, SourceReference,Filter; blank = inherited). - Commons: NativeAlarmSourceOverrideEntry + bulk SetInstanceNativeAlarmSource- OverridesCommand (auto-registered via the reflection command registry). - ManagementService: Deployer-gated handler — flattens once, validates every source resolves + is unlocked + no duplicates up front, then upserts the whole batch under a single SaveChanges (true all-or-nothing txn). Added to the frozen authorization matrix; dispatch-coverage guard passes. - CLI: `instance native-alarm-source import --instance-id --file` (parity with `instance import-overrides`) + README. - Tests: native parser (Commons), CLI parse/entry mapping, 3 bulk-handler tests (happy path single-commit, locked-source reject, unresolved-source reject). Also corrects a stale XML-doc line in ScriptRuntimeContext (WaitForAttribute quality-gated mode is shipped, not "planned") and updates the deferred-work register: marks #7/#13/#15/#16/#20 verified-resolved, #12 as CLI/API-shipped with only the Central UI upload affordance still pending. Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.CLI.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the CSV parsing + entry-mapping behaviour that backs
|
||||
/// <c>instance native-alarm-source import</c>: parse errors block the apply step;
|
||||
/// valid CSV produces the <see cref="NativeAlarmSourceOverrideEntry"/> list the
|
||||
/// <see cref="SetInstanceNativeAlarmSourceOverridesCommand"/> consumes.
|
||||
/// </summary>
|
||||
public class ImportNativeAlarmSourceOverridesTests
|
||||
{
|
||||
// ── error path ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void CsvWithMissingHeader_ProducesErrors()
|
||||
{
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse("Pressure,ConnA,ref,Active");
|
||||
|
||||
Assert.NotEmpty(result.Errors);
|
||||
Assert.Empty(result.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CsvWithBlankSourceName_ProducesError()
|
||||
{
|
||||
const string csv = "SourceName,Connection,SourceReference,Filter\n,ConnA,ref,Active";
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.NotEmpty(result.Errors);
|
||||
Assert.Empty(result.Rows);
|
||||
}
|
||||
|
||||
// ── happy path ───────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void ValidCsv_ProducesCorrectEntryList()
|
||||
{
|
||||
const string csv = """
|
||||
SourceName,Connection,SourceReference,Filter
|
||||
Pressure,ConnA,ns=2;s=Pump,Active
|
||||
Temp,,,
|
||||
""";
|
||||
|
||||
var parseResult = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.Empty(parseResult.Errors);
|
||||
Assert.Equal(2, parseResult.Rows.Count);
|
||||
|
||||
// Build the same entry list that the CLI import action produces.
|
||||
var entries = parseResult.Rows
|
||||
.Select(r => new NativeAlarmSourceOverrideEntry(
|
||||
r.SourceName, r.Connection, r.SourceReference, r.Filter))
|
||||
.ToList();
|
||||
|
||||
Assert.Equal("Pressure", entries[0].SourceCanonicalName);
|
||||
Assert.Equal("ConnA", entries[0].ConnectionNameOverride);
|
||||
Assert.Equal("ns=2;s=Pump", entries[0].SourceReferenceOverride);
|
||||
Assert.Equal("Active", entries[0].ConditionFilterOverride);
|
||||
|
||||
// All-blank override fields → inherited (null) on every field.
|
||||
Assert.Equal("Temp", entries[1].SourceCanonicalName);
|
||||
Assert.Null(entries[1].ConnectionNameOverride);
|
||||
Assert.Null(entries[1].SourceReferenceOverride);
|
||||
Assert.Null(entries[1].ConditionFilterOverride);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Commons.Tests;
|
||||
|
||||
public class NativeAlarmSourceOverrideCsvParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_SimpleFile_ReturnsRowsNoErrors()
|
||||
{
|
||||
const string csv =
|
||||
"SourceName,Connection,SourceReference,Filter\n" +
|
||||
"Pressure,ConnA,ns=2;s=Pump.Alarm,Active\n" +
|
||||
"Module.Temp,ConnB,ns=2;s=Temp,\n";
|
||||
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.Equal(2, result.Rows.Count);
|
||||
|
||||
Assert.Equal("Pressure", result.Rows[0].SourceName);
|
||||
Assert.Equal("ConnA", result.Rows[0].Connection);
|
||||
Assert.Equal("ns=2;s=Pump.Alarm", result.Rows[0].SourceReference);
|
||||
Assert.Equal("Active", result.Rows[0].Filter);
|
||||
Assert.Equal(2, result.Rows[0].LineNumber);
|
||||
|
||||
Assert.Equal("Module.Temp", result.Rows[1].SourceName);
|
||||
Assert.Equal("ConnB", result.Rows[1].Connection);
|
||||
Assert.Equal("ns=2;s=Temp", result.Rows[1].SourceReference);
|
||||
Assert.Null(result.Rows[1].Filter);
|
||||
Assert.Equal(3, result.Rows[1].LineNumber);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BlankOverrideFields_BecomeNullInherited()
|
||||
{
|
||||
const string csv =
|
||||
"SourceName,Connection,SourceReference,Filter\n" +
|
||||
"Pressure,,,\n";
|
||||
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
var row = Assert.Single(result.Rows);
|
||||
Assert.Equal("Pressure", row.SourceName);
|
||||
Assert.Null(row.Connection);
|
||||
Assert.Null(row.SourceReference);
|
||||
Assert.Null(row.Filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_HeaderCaseInsensitive_Accepted()
|
||||
{
|
||||
const string csv =
|
||||
"sourcename,CONNECTION,SourceReference,filter\n" +
|
||||
"Pressure,ConnA,ref,\n";
|
||||
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.Single(result.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_QuotedFieldWithComma_PreservesEmbeddedComma()
|
||||
{
|
||||
const string csv =
|
||||
"SourceName,Connection,SourceReference,Filter\n" +
|
||||
"Pressure,ConnA,\"ns=2;s=a,b,c\",Active\n";
|
||||
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
var row = Assert.Single(result.Rows);
|
||||
Assert.Equal("ns=2;s=a,b,c", row.SourceReference);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MissingHeader_ReturnsHeaderError()
|
||||
{
|
||||
const string csv = "Pressure,ConnA,ref,Active\n";
|
||||
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.Empty(result.Rows);
|
||||
var error = Assert.Single(result.Errors);
|
||||
Assert.Contains("SourceName,Connection,SourceReference,Filter", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_WrongColumnCount_ReportsPerLineErrorAndSkipsRow()
|
||||
{
|
||||
const string csv =
|
||||
"SourceName,Connection,SourceReference,Filter\n" +
|
||||
"Pressure,ConnA\n" +
|
||||
"Temp,ConnB,ref,Active\n";
|
||||
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
var error = Assert.Single(result.Errors);
|
||||
Assert.Contains("Line 2", error);
|
||||
Assert.Contains("expected 4 columns", error);
|
||||
// Valid rows still flow through.
|
||||
var row = Assert.Single(result.Rows);
|
||||
Assert.Equal("Temp", row.SourceName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BlankSourceName_ReportsError()
|
||||
{
|
||||
const string csv =
|
||||
"SourceName,Connection,SourceReference,Filter\n" +
|
||||
",ConnA,ref,Active\n";
|
||||
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.Empty(result.Rows);
|
||||
var error = Assert.Single(result.Errors);
|
||||
Assert.Contains("SourceName must not be blank", error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UnterminatedQuote_ReportsError()
|
||||
{
|
||||
const string csv =
|
||||
"SourceName,Connection,SourceReference,Filter\n" +
|
||||
"Pressure,ConnA,\"ns=2;s=open,Active\n";
|
||||
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.Empty(result.Rows);
|
||||
Assert.Contains(result.Errors, e => e.Contains("Unterminated quoted field"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_BlankLinesSkippedWithoutError()
|
||||
{
|
||||
const string csv =
|
||||
"SourceName,Connection,SourceReference,Filter\n" +
|
||||
"\n" +
|
||||
"Pressure,ConnA,ref,Active\n" +
|
||||
" \n";
|
||||
|
||||
var result = NativeAlarmSourceOverrideCsvParser.Parse(csv);
|
||||
|
||||
Assert.Empty(result.Errors);
|
||||
Assert.Single(result.Rows);
|
||||
}
|
||||
}
|
||||
@@ -2874,6 +2874,112 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Contains("Deployer", response.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInstanceNativeAlarmSourceOverrides_BulkHappyPath_AddsAllAndSavesOnce()
|
||||
{
|
||||
// #12: bulk CSV apply. Both sources resolve and are unlocked → each is added,
|
||||
// and the whole batch commits with a SINGLE SaveChanges (all-or-nothing txn).
|
||||
var pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
var flattened = new FlattenedConfiguration
|
||||
{
|
||||
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
|
||||
{
|
||||
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" },
|
||||
new() { CanonicalName = "Temp", IsLocked = false, Source = "Template" }
|
||||
}
|
||||
};
|
||||
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
|
||||
_services.AddScoped(_ => pipeline);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
|
||||
{
|
||||
new("Pressure", "Opc2", "ns=2;s=P", null),
|
||||
new("Temp", null, "ns=2;s=T", "Active")
|
||||
}),
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
_templateRepo.ReceivedWithAnyArgs(2).AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
|
||||
// All-or-nothing: exactly one commit for the whole batch.
|
||||
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInstanceNativeAlarmSourceOverrides_OneLockedSource_RejectsWholeBatch()
|
||||
{
|
||||
// #12: all-or-nothing. If ANY entry targets a locked source, the whole batch
|
||||
// is rejected before any write — no Add, no SaveChanges.
|
||||
var pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
var flattened = new FlattenedConfiguration
|
||||
{
|
||||
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
|
||||
{
|
||||
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" },
|
||||
new() { CanonicalName = "Locked", IsLocked = true, Source = "Template" }
|
||||
}
|
||||
};
|
||||
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
|
||||
_services.AddScoped(_ => pipeline);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
|
||||
{
|
||||
new("Pressure", "Opc2", null, null),
|
||||
new("Locked", "Opc2", null, null)
|
||||
}),
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("locked", response.Error, StringComparison.OrdinalIgnoreCase);
|
||||
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
|
||||
_templateRepo.DidNotReceiveWithAnyArgs().SaveChangesAsync(default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetInstanceNativeAlarmSourceOverrides_UnresolvedSource_RejectsWholeBatch()
|
||||
{
|
||||
// #12: a source that does not resolve for the instance is rejected up front.
|
||||
var pipeline = Substitute.For<IFlatteningPipeline>();
|
||||
var flattened = new FlattenedConfiguration
|
||||
{
|
||||
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
|
||||
{
|
||||
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" }
|
||||
}
|
||||
};
|
||||
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||
.Returns(Result<FlatteningPipelineResult>.Success(
|
||||
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
|
||||
_services.AddScoped(_ => pipeline);
|
||||
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(
|
||||
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
|
||||
{
|
||||
new("Pressure", "Opc2", null, null),
|
||||
new("Ghost", "Opc2", null, null)
|
||||
}),
|
||||
"Deployer");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("does not resolve", response.Error, StringComparison.OrdinalIgnoreCase);
|
||||
_templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
|
||||
_templateRepo.DidNotReceiveWithAnyArgs().SaveChangesAsync(default);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MoveDataConnectionCommand (M9 / T24a) — guarded cross-site move
|
||||
// ========================================================================
|
||||
|
||||
@@ -124,6 +124,7 @@ public class RequiredRoleMatrixTests
|
||||
["SetInstanceAlarmOverride"] = [Roles.Deployer],
|
||||
["DeleteInstanceAlarmOverride"] = [Roles.Deployer],
|
||||
["SetInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
|
||||
["SetInstanceNativeAlarmSourceOverrides"] = [Roles.Deployer],
|
||||
["DeleteInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
|
||||
["GetDeploymentDiff"] = [Roles.Deployer],
|
||||
["MgmtDeployArtifacts"] = [Roles.Deployer],
|
||||
|
||||
Reference in New Issue
Block a user