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:
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user