fix(native-alarms): a 4-column CSV import keeps stored MES overrides on the CLI path too (ScadaBridge#104)

The bulk SetInstanceNativeAlarmSourceOverridesCommand handler always wrote the
entry's IsMesReportableOverride, so a CLI import of a legacy 4-column CSV (no
MesReportable column) reset every imported source's MES override to inherit. The
Central UI avoided it only by looking up the stored flag client-side.

NativeAlarmSourceOverrideEntry gains an additive trailing KeepStoredMesReportable
flag: when set, the server leaves an existing row's MES override untouched (a new
row inherits) and ignores IsMesReportableOverride; unset keeps the old meaning
(null = clear to inherit), so older senders are unaffected. The CLI and the Central
UI now build their entries through one rule (ToOverrideEntry on each row type):
no MesReportable column => KeepStoredMesReportable. CLI README and
Component-CentralUI updated.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 15:41:33 -05:00
parent a81dac76ed
commit 063fb21815
11 changed files with 197 additions and 16 deletions
@@ -68,4 +68,35 @@ public class ImportNativeAlarmSourceOverridesTests
Assert.Null(entries[1].SourceReferenceOverride);
Assert.Null(entries[1].ConditionFilterOverride);
}
// ── MES flag (ScadaBridge#104) ───────────────────────────────────────────
/// <summary>A legacy 4-column file leaves the MES flag unspecified, so the server keeps it.</summary>
[Fact]
public void FourColumnCsv_EntryKeepsStoredMesFlag()
{
var parsed = NativeAlarmSourceOverrideCsvParser.Parse("SourceName,Connection,SourceReference,Filter\nPressure,ConnA,,");
var entry = Assert.Single(parsed.Rows).ToOverrideEntry();
Assert.True(entry.KeepStoredMesReportable);
Assert.Null(entry.IsMesReportableOverride);
Assert.Equal("ConnA", entry.ConnectionNameOverride);
}
/// <summary>A file with the MesReportable column is authoritative, blank included (= inherit).</summary>
[Fact]
public void FiveColumnCsv_EntrySpecifiesMesFlag()
{
var parsed = NativeAlarmSourceOverrideCsvParser.Parse(
"SourceName,Connection,SourceReference,Filter,MesReportable\nPressure,,,,false\nTemp,,,,");
var pressure = parsed.Rows[0].ToOverrideEntry();
var temp = parsed.Rows[1].ToOverrideEntry();
Assert.False(pressure.KeepStoredMesReportable);
Assert.False(pressure.IsMesReportableOverride);
Assert.False(temp.KeepStoredMesReportable);
Assert.Null(temp.IsMesReportableOverride);
}
}
@@ -102,6 +102,25 @@ public class InstanceConfigureNativeAlarmCsvImportTests
Assert.Null(outcome.Rows[1].MesReportable);
}
/// <summary>
/// ScadaBridge#104: the UI sends the same entry as the CLI — a 4-column file leaves the MES
/// flag to the server (keep stored), a 5-column file specifies it.
/// </summary>
[Fact]
public void ImportRows_MapToTheSameEntriesAsTheCli()
{
var legacy = InstanceConfigure.BuildNativeAlarmSourceCsvImport(
NativeAlarmSourceOverrideCsvParser.Parse("SourceName,Connection,SourceReference,Filter\nBoilerAlarms,,,\n"),
Sources());
var withMes = InstanceConfigure.BuildNativeAlarmSourceCsvImport(
NativeAlarmSourceOverrideCsvParser.Parse("SourceName,Connection,SourceReference,Filter,MesReportable\nBoilerAlarms,,,,\n"),
Sources());
Assert.True(legacy.Rows[0].ToOverrideEntry().KeepStoredMesReportable);
Assert.False(withMes.Rows[0].ToOverrideEntry().KeepStoredMesReportable);
Assert.Null(withMes.Rows[0].ToOverrideEntry().IsMesReportableOverride);
}
/// <summary>Verifies that a row with all fields blank is marked as a clear.</summary>
[Fact]
public void AllBlankRow_IsMarkedAsClear()
@@ -4789,6 +4789,102 @@ public class ManagementActorTests : TestKit, IDisposable
_templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
}
private void StubFlattenedNativeSources(params string[] names)
{
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = names
.Select(n => new ResolvedNativeAlarmSource { CanonicalName = n, IsLocked = false, Source = "Template" })
.ToList()
};
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
}
/// <summary>
/// ScadaBridge#104: an entry that leaves the MES flag unspecified (a legacy 4-column CSV)
/// keeps the stored MES override on an existing row while the retarget fields are applied.
/// </summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_MesUnspecified_KeepsStoredMesOverride()
{
StubFlattenedNativeSources("Pressure");
var existing = new InstanceNativeAlarmSourceOverride("Pressure")
{
InstanceId = 1, ConnectionNameOverride = "Old", IsMesReportableOverride = true
};
_templateRepo.GetNativeAlarmSourceOverrideAsync(1, "Pressure", Arg.Any<CancellationToken>())
.Returns(existing);
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null, IsMesReportableOverride: null, KeepStoredMesReportable: true)
}),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal("Opc2", existing.ConnectionNameOverride);
Assert.True(existing.IsMesReportableOverride);
}
/// <summary>
/// ScadaBridge#104 control: an entry that specifies the flag (a 5-column CSV with a blank
/// MesReportable) still clears the stored MES override to inherit.
/// </summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_MesSpecifiedNull_ClearsStoredMesOverride()
{
StubFlattenedNativeSources("Pressure");
var existing = new InstanceNativeAlarmSourceOverride("Pressure")
{
InstanceId = 1, ConnectionNameOverride = "Old", IsMesReportableOverride = true
};
_templateRepo.GetNativeAlarmSourceOverrideAsync(1, "Pressure", Arg.Any<CancellationToken>())
.Returns(existing);
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null, IsMesReportableOverride: null)
}),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Null(existing.IsMesReportableOverride);
}
/// <summary>
/// ScadaBridge#104: a new override row whose entry leaves the MES flag unspecified inherits
/// it, even if the entry carries a stray value.
/// </summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_MesUnspecified_NewRowInherits()
{
StubFlattenedNativeSources("Pressure");
InstanceNativeAlarmSourceOverride? added = null;
_templateRepo.AddInstanceNativeAlarmSourceOverrideAsync(
Arg.Do<InstanceNativeAlarmSourceOverride>(o => added = o), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
var actor = CreateActor();
actor.Tell(Envelope(
new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
{
new("Pressure", "Opc2", null, null, IsMesReportableOverride: true, KeepStoredMesReportable: true)
}),
"Deployer"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(added);
Assert.Null(added!.IsMesReportableOverride);
}
/// <summary>Verifies that set instance native alarm source overrides, when one locked source, rejects whole batch.</summary>
[Fact]
public void SetInstanceNativeAlarmSourceOverrides_OneLockedSource_RejectsWholeBatch()