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:
@@ -18,23 +18,30 @@ them and are removed from this table when that plan's task lands.
|
|||||||
## Deferred (with rationale + revisit trigger)
|
## Deferred (with rationale + revisit trigger)
|
||||||
| # | Item | Where noted | Rationale for deferral | Revisit trigger |
|
| # | Item | Where noted | Rationale for deferral | Revisit trigger |
|
||||||
|---|------|-------------|------------------------|-----------------|
|
|---|------|-------------|------------------------|-----------------|
|
||||||
| 7 | SecuredWrite audit rows leave SourceNode NULL | CLAUDE.md Security | Logged follow-up; low-volume channel | First per-node forensic query against secured writes |
|
|
||||||
| 8 | Hash-chain tamper evidence (T1); CLI verify-chain is a no-op stub | audit-log roadmap :12 | v1.x by locked decision; append-only DB roles are the control | Compliance requirement for cryptographic tamper evidence |
|
| 8 | Hash-chain tamper evidence (T1); CLI verify-chain is a no-op stub | audit-log roadmap :12 | v1.x by locked decision; append-only DB roles are the control | Compliance requirement for cryptographic tamper evidence |
|
||||||
| 9 | Parquet audit archival (T2); endpoint returns 501 | AuditEndpoints.cs:204 | v1.x; 501 + CLI messaging are honest | AuditLog partition volume nears retention ceiling |
|
| 9 | Parquet audit archival (T2); endpoint returns 501 | AuditEndpoints.cs:204 | v1.x; 501 + CLI messaging are honest | AuditLog partition volume nears retention ceiling |
|
||||||
| 10 | Aggregated live alarm stream for Alarm Summary | m7 design :252 | Snapshot fan-out acceptable at current instance counts | Alarm Summary latency complaints or >~50 instances/site |
|
| 10 | Aggregated live alarm stream for Alarm Summary | m7 design :252 | Snapshot fan-out acceptable at current instance counts | Alarm Summary latency complaints or >~50 instances/site |
|
||||||
| 11 | Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
|
| 11 | Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
|
||||||
| 12 | Native-alarm-source-override CSV import | m7 follow-ups | Parity gap only; attribute CSV shipped | First bulk native-alarm rollout request |
|
| 12 | Native-alarm-source-override CSV import — **Central UI `InstanceConfigure` upload affordance only** (CLI + Management API + parser shipped 2026-07-10, see Resolved) | m7 follow-ups | CLI/API path closes the operator parity gap; the Blazor upload button is polish | First request to bulk-import native sources from the UI rather than the CLI |
|
||||||
| 13 | WaitForAttribute quality-gated mode | ScriptRuntimeContext.cs:405 | Planned enhancement per spec §4.2 | First script needing Good-only waits |
|
| 14 | WaitForAttribute in Test-Run sandbox | waitfor-deferred :222 | Test-Run parity gap; production unaffected (sandbox throws a clearly-labelled ScriptSandboxException; deploy to a site to exercise) | Author feedback on Test-Run fidelity |
|
||||||
| 14 | WaitForAttribute in Test-Run sandbox | waitfor-deferred :222 | Test-Run parity gap; production unaffected | Author feedback on Test-Run fidelity |
|
|
||||||
| 15 | BrowseNext final-page signal not surfaced | BrowseCommands.cs:34 | One wasted round-trip | UX complaint on browse paging |
|
|
||||||
| 16 | StubOpcUaClient throws on browse | m7 design :245 | Limits offline UI coverage only | Next browse/search UI regression |
|
|
||||||
| 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
|
| 17 | Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
|
||||||
| 18 | Folder drag-drop | same, [PERM] | Permanently closed; menu reorder shipped | — (closed) |
|
| 18 | Folder drag-drop | same, [PERM] | Permanently closed; menu reorder shipped | — (closed) |
|
||||||
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
|
| 19 | Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
|
||||||
| 20 | Deployment EXPIRED-row purge | DeploymentManagerRepository.cs:310 | Read path already compensates | EXPIRED rows visible in ops queries |
|
|
||||||
| 21 | SiteAuditBacklogReporter threshold consolidation | SiteAuditBacklogReporter.cs:28 | Config-shape cleanup only | Next SqliteAuditWriterOptions change |
|
| 21 | SiteAuditBacklogReporter threshold consolidation | SiteAuditBacklogReporter.cs:28 | Config-shape cleanup only | Next SqliteAuditWriterOptions change |
|
||||||
| 22 | KPI history hourly rollups | Component-KpiHistory.md | YAGNI; 90-day retention bounds table | KpiSample query latency on dashboards |
|
| 22 | KPI history hourly rollups | Component-KpiHistory.md | YAGNI; 90-day retention bounds table | KpiSample query latency on dashboards |
|
||||||
|
|
||||||
|
## Resolved (verified against the code 2026-07-10)
|
||||||
|
Rows removed from the Deferred table above once confirmed shipped. Kept here for traceability.
|
||||||
|
|
||||||
|
| # | Item | Resolution |
|
||||||
|
|---|------|-----------|
|
||||||
|
| 7 | SecuredWrite audit rows leave SourceNode NULL | Resolved (PLAN-07): `ManagementActor.EmitSecuredWriteAuditAsync` routes through `ICentralAuditWriter`, which stamps `SourceNode` (`central-a`/`central-b`) from `INodeIdentityProvider`. |
|
||||||
|
| 12 (CLI/API) | Native-alarm-source-override CSV import | Shipped 2026-07-10: shared `CsvLineSplitter`, `NativeAlarmSourceOverrideCsvParser`, bulk all-or-nothing `SetInstanceNativeAlarmSourceOverridesCommand` + ManagementActor handler (Deployer-gated), CLI `instance native-alarm-source import --file`, parser/CLI/handler tests. **UI upload affordance still pending — see row 12 above.** |
|
||||||
|
| 13 | WaitForAttribute quality-gated ("Good"-only) mode | Already implemented (Commons `WaitForAttribute.RequireGoodQuality`, enforced in `InstanceActor`, threaded through `ScriptRuntimeContext`, tested in `InstanceActorWaitForAttributeTests`). Stale "planned enhancement" doc line corrected 2026-07-10. |
|
||||||
|
| 15 | BrowseNext final-page signal not surfaced | Already surfaced (M7 browse work): `RealOpcUaClient` sets `Truncated=false`/`ContinuationToken=null` on the last page; `BrowseNodeResult` carries both; `TreeRow.razor` renders "Load more" only when a continuation token remains — no wasted BrowseNext. |
|
||||||
|
| 16 | StubOpcUaClient throws on browse | Already resolved: `StubOpcUaClient` supports browse + address-space search, covered by `StubOpcUaClientBrowseTests`/`StubOpcUaClientSearchTests`. |
|
||||||
|
| 20 | Deployment EXPIRED-row purge | Already resolved (PLAN-04): `PendingDeploymentPurgeActor` central singleton (spawned in `AkkaHostedService`) ticks `IDeploymentManagerRepository.PurgeExpiredPendingDeploymentsAsync` every `CommunicationOptions.PendingDeploymentPurgeInterval` (default 1h), options-validated, tested. |
|
||||||
|
|
||||||
## New deferrals from review 08 (this plan)
|
## New deferrals from review 08 (this plan)
|
||||||
| Item | Rationale | Revisit trigger |
|
| Item | Rationale | Revisit trigger |
|
||||||
|------|-----------|-----------------|
|
|------|-----------|-----------------|
|
||||||
|
|||||||
@@ -477,6 +477,53 @@ public static class InstanceCommands
|
|||||||
});
|
});
|
||||||
group.Add(clearCmd);
|
group.Add(clearCmd);
|
||||||
|
|
||||||
|
// import (CSV bulk apply — parity with `instance import-overrides`)
|
||||||
|
var importIdOption = new Option<int>("--instance-id") { Description = "Instance ID", Required = true };
|
||||||
|
var importFileOption = new Option<string>("--file")
|
||||||
|
{
|
||||||
|
Description = "Path to the override CSV file (columns: SourceName,Connection,SourceReference,Filter; blank field = inherited)",
|
||||||
|
Required = true
|
||||||
|
};
|
||||||
|
var importCmd = new Command("import")
|
||||||
|
{
|
||||||
|
Description = "Apply native alarm source overrides from a CSV file (all-or-nothing)"
|
||||||
|
};
|
||||||
|
importCmd.Add(importIdOption);
|
||||||
|
importCmd.Add(importFileOption);
|
||||||
|
importCmd.SetAction(async (ParseResult result) =>
|
||||||
|
{
|
||||||
|
var id = result.GetValue(importIdOption);
|
||||||
|
var filePath = result.GetValue(importFileOption)!;
|
||||||
|
|
||||||
|
string csvText;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
csvText = File.ReadAllText(filePath);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OutputFormatter.WriteError($"Cannot read file '{filePath}': {ex.Message}", "FILE_READ_ERROR");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parseResult = NativeAlarmSourceOverrideCsvParser.Parse(csvText);
|
||||||
|
if (parseResult.Errors.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var error in parseResult.Errors)
|
||||||
|
OutputFormatter.WriteError(error, "INVALID_CSV");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var overrides = parseResult.Rows
|
||||||
|
.Select(r => new NativeAlarmSourceOverrideEntry(
|
||||||
|
r.SourceName, r.Connection, r.SourceReference, r.Filter))
|
||||||
|
.ToList();
|
||||||
|
return await CommandHelpers.ExecuteCommandAsync(
|
||||||
|
result, urlOption, formatOption, usernameOption, passwordOption,
|
||||||
|
new SetInstanceNativeAlarmSourceOverridesCommand(id, overrides));
|
||||||
|
});
|
||||||
|
group.Add(importCmd);
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -503,6 +503,31 @@ Clear an instance's native alarm source override, reverting to the inherited bin
|
|||||||
scadabridge --url <url> instance native-alarm-source clear --instance-id <int> --source <string>
|
scadabridge --url <url> instance native-alarm-source clear --instance-id <int> --source <string>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `instance native-alarm-source import`
|
||||||
|
|
||||||
|
Bulk-apply native alarm source overrides for a single instance from a CSV file
|
||||||
|
(all-or-nothing — the native-source parity of `instance import-overrides`). The CSV
|
||||||
|
header is required: `SourceName,Connection,SourceReference,Filter`; a blank override
|
||||||
|
field keeps the inherited value. Every named source must resolve for the instance and
|
||||||
|
be unlocked, or the whole batch is rejected before any write.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scadabridge --url <url> instance native-alarm-source import --instance-id <int> --file <path>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `--instance-id` | yes | Instance ID |
|
||||||
|
| `--file` | yes | Path to the override CSV (`SourceName,Connection,SourceReference,Filter`; blank field = inherited) |
|
||||||
|
|
||||||
|
Example CSV:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
SourceName,Connection,SourceReference,Filter
|
||||||
|
Pressure,OpcB,ns=2;s=Pump.Alarm,Active
|
||||||
|
Module.Temp,,,
|
||||||
|
```
|
||||||
|
|
||||||
#### `instance deploy`
|
#### `instance deploy`
|
||||||
|
|
||||||
Deploy an instance to its site. Acquires the per-instance operation lock.
|
Deploy an instance to its site. Acquires the per-instance operation lock.
|
||||||
|
|||||||
@@ -62,3 +62,26 @@ public record DeleteInstanceNativeAlarmSourceOverrideCommand(
|
|||||||
string SourceCanonicalName);
|
string SourceCanonicalName);
|
||||||
|
|
||||||
public record ListInstanceNativeAlarmSourceOverridesCommand(int InstanceId);
|
public record ListInstanceNativeAlarmSourceOverridesCommand(int InstanceId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One entry in a bulk native-alarm-source-override apply: the source binding's
|
||||||
|
/// canonical name plus the three optional retarget fields (null keeps the inherited
|
||||||
|
/// value). Mirrors <see cref="SetInstanceNativeAlarmSourceOverrideCommand"/>'s fields
|
||||||
|
/// for a single source.
|
||||||
|
/// </summary>
|
||||||
|
public record NativeAlarmSourceOverrideEntry(
|
||||||
|
string SourceCanonicalName,
|
||||||
|
string? ConnectionNameOverride,
|
||||||
|
string? SourceReferenceOverride,
|
||||||
|
string? ConditionFilterOverride);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk, all-or-nothing apply of per-instance native-alarm-source overrides — the
|
||||||
|
/// native-source parity analogue of <see cref="SetInstanceOverridesCommand"/>. Backs
|
||||||
|
/// the CLI <c>instance native-alarm-source import --file</c> CSV path. Every entry's
|
||||||
|
/// source must resolve for the instance and be unlocked; if any entry is invalid the
|
||||||
|
/// whole batch is rejected before any write.
|
||||||
|
/// </summary>
|
||||||
|
public record SetInstanceNativeAlarmSourceOverridesCommand(
|
||||||
|
int InstanceId,
|
||||||
|
IReadOnlyList<NativeAlarmSourceOverrideEntry> Overrides);
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared, dependency-free RFC-4180-ish splitter for a single physical CSV line.
|
||||||
|
/// Extracted so the instance-override CSV parsers (<see cref="OverrideCsvParser"/>
|
||||||
|
/// and <see cref="NativeAlarmSourceOverrideCsvParser"/>) apply identical quoting
|
||||||
|
/// rules without duplicating the subtle field-state machine.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Quoting rules:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>A field is <i>quoted</i> only if its first non-whitespace char is a
|
||||||
|
/// <c>"</c> (leading whitespace before the opening quote is allowed and ignored).
|
||||||
|
/// Inside a quoted field, commas are literal and <c>""</c> is an escaped single
|
||||||
|
/// quote; the closing <c>"</c> must be the last non-whitespace char of the field
|
||||||
|
/// (trailing whitespace after the close is allowed and ignored).</item>
|
||||||
|
/// <item>A <c>"</c> appearing anywhere else in an unquoted field (i.e. after
|
||||||
|
/// non-whitespace content) is a <b>literal</b> character and is preserved.</item>
|
||||||
|
/// <item>Unquoted fields are whitespace-trimmed; quoted field values are kept
|
||||||
|
/// verbatim.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </remarks>
|
||||||
|
public static class CsvLineSplitter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a single physical CSV line into its fields, applying the quoting rules
|
||||||
|
/// described on <see cref="CsvLineSplitter"/>. Returns <c>true</c> with the split
|
||||||
|
/// <paramref name="fields"/> on success; returns <c>false</c> when a quoted field
|
||||||
|
/// is opened but never closed before end-of-line (the caller emits a per-line
|
||||||
|
/// "unterminated" error).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="line">The physical line to split (no trailing newline).</param>
|
||||||
|
/// <param name="fields">The split fields on success; an empty list on failure.</param>
|
||||||
|
/// <returns><c>true</c> when the line split cleanly; <c>false</c> on an unterminated quoted field.</returns>
|
||||||
|
public static bool TrySplit(string line, out List<string> fields)
|
||||||
|
{
|
||||||
|
fields = new List<string>();
|
||||||
|
var field = new System.Text.StringBuilder();
|
||||||
|
var inQuotes = false; // currently between an opening and closing quote
|
||||||
|
var quoted = false; // this field opened with a quote → keep value verbatim
|
||||||
|
var sawContent = false; // any non-whitespace char seen in the current field yet
|
||||||
|
|
||||||
|
for (var i = 0; i < line.Length; i++)
|
||||||
|
{
|
||||||
|
var c = line[i];
|
||||||
|
|
||||||
|
if (inQuotes)
|
||||||
|
{
|
||||||
|
if (c == '"')
|
||||||
|
{
|
||||||
|
// Doubled quote inside a quoted field → a single literal quote.
|
||||||
|
if (i + 1 < line.Length && line[i + 1] == '"')
|
||||||
|
{
|
||||||
|
field.Append('"');
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inQuotes = false; // closing quote; only trailing whitespace may follow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
field.Append(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (c)
|
||||||
|
{
|
||||||
|
case ',':
|
||||||
|
fields.Add(Finalize(field, quoted));
|
||||||
|
field.Clear();
|
||||||
|
inQuotes = false;
|
||||||
|
quoted = false;
|
||||||
|
sawContent = false;
|
||||||
|
break;
|
||||||
|
case '"' when !sawContent:
|
||||||
|
// Opening quote: first non-whitespace char of the field. Any
|
||||||
|
// leading whitespace seen so far is part of the (ignored) prefix.
|
||||||
|
field.Clear();
|
||||||
|
inQuotes = true;
|
||||||
|
quoted = true;
|
||||||
|
sawContent = true;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// After a quoted field has closed, only whitespace may appear
|
||||||
|
// before the next delimiter — it is ignored, not appended.
|
||||||
|
if (quoted)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// A '"' here (sawContent already true) falls through as a literal.
|
||||||
|
if (!char.IsWhiteSpace(c))
|
||||||
|
sawContent = true;
|
||||||
|
field.Append(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inQuotes)
|
||||||
|
return false; // opened a quoted field that was never closed
|
||||||
|
|
||||||
|
fields.Add(Finalize(field, quoted));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Finalize(System.Text.StringBuilder field, bool quoted)
|
||||||
|
{
|
||||||
|
var text = field.ToString();
|
||||||
|
return quoted ? text : text.Trim(); // only unquoted whitespace is trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
namespace ZB.MOM.WW.ScadaBridge.Commons.Types;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One parsed per-instance native-alarm-source-override CSV row: the source
|
||||||
|
/// binding's canonical name plus the three optional retarget fields. A blank
|
||||||
|
/// (null) <see cref="Connection"/>/<see cref="SourceReference"/>/<see cref="Filter"/>
|
||||||
|
/// means "keep the inherited value" — the same blank-is-inherited semantics as the
|
||||||
|
/// CLI <c>instance native-alarm-source set</c> command. <see cref="LineNumber"/> is
|
||||||
|
/// the 1-based source line (the header is line 1) so downstream errors can point
|
||||||
|
/// back at the operator's file.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record NativeAlarmSourceOverrideCsvRow(
|
||||||
|
string SourceName, string? Connection, string? SourceReference, string? Filter, int LineNumber);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Outcome of parsing a native-alarm-source-override CSV: the successfully-parsed
|
||||||
|
/// <see cref="Rows"/> plus per-line <see cref="Errors"/>. Parsing never throws —
|
||||||
|
/// malformed rows are reported and excluded, valid rows still flow through.
|
||||||
|
/// Downstream callers validate source names against the flattened instance; this
|
||||||
|
/// parser is purely syntactic.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record NativeAlarmSourceOverrideCsvParseResult(
|
||||||
|
IReadOnlyList<NativeAlarmSourceOverrideCsvRow> Rows, IReadOnlyList<string> Errors);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure, dependency-free, quote-aware parser turning per-instance
|
||||||
|
/// native-alarm-source-override CSV text into structured rows plus per-line errors.
|
||||||
|
/// Callers supply the text (no file I/O). The header row is required and
|
||||||
|
/// case-insensitive (<c>SourceName,Connection,SourceReference,Filter</c>). Fields
|
||||||
|
/// follow the shared <see cref="CsvLineSplitter"/> RFC-4180 quoting rules — the same
|
||||||
|
/// splitter <see cref="OverrideCsvParser"/> uses. The parity analogue of
|
||||||
|
/// <see cref="OverrideCsvParser"/> for native alarm source retargets.
|
||||||
|
/// </summary>
|
||||||
|
public static class NativeAlarmSourceOverrideCsvParser
|
||||||
|
{
|
||||||
|
private const string HeaderError =
|
||||||
|
"Missing or invalid header row. Expected 'SourceName,Connection,SourceReference,Filter'.";
|
||||||
|
|
||||||
|
private const int ExpectedColumns = 4;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses native-alarm-source-override CSV <paramref name="csvText"/>. Returns
|
||||||
|
/// parsed rows and any per-line errors; never throws. On a missing/unrecognized
|
||||||
|
/// header returns zero rows and a single header error.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="csvText">The raw CSV text to parse.</param>
|
||||||
|
/// <returns>The parsed rows and any per-line errors.</returns>
|
||||||
|
public static NativeAlarmSourceOverrideCsvParseResult Parse(string csvText)
|
||||||
|
{
|
||||||
|
var rows = new List<NativeAlarmSourceOverrideCsvRow>();
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
// Split into physical lines; \r\n and \r are normalized to \n boundaries.
|
||||||
|
var lines = (csvText ?? string.Empty).Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
|
||||||
|
|
||||||
|
var headerSeen = false;
|
||||||
|
|
||||||
|
for (var i = 0; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
var lineNumber = i + 1;
|
||||||
|
var rawLine = lines[i];
|
||||||
|
|
||||||
|
// Skip fully-blank lines (whitespace-only included) without error.
|
||||||
|
if (string.IsNullOrWhiteSpace(rawLine))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (!CsvLineSplitter.TrySplit(rawLine, out var fields))
|
||||||
|
{
|
||||||
|
errors.Add($"Line {lineNumber}: Unterminated quoted field.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headerSeen)
|
||||||
|
{
|
||||||
|
if (!TryMatchHeader(fields))
|
||||||
|
{
|
||||||
|
errors.Add(HeaderError);
|
||||||
|
return new NativeAlarmSourceOverrideCsvParseResult(rows, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
headerSeen = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.Count != ExpectedColumns)
|
||||||
|
{
|
||||||
|
errors.Add(
|
||||||
|
$"Line {lineNumber}: expected {ExpectedColumns} columns but found {fields.Count}.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sourceName = fields[0];
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceName))
|
||||||
|
{
|
||||||
|
errors.Add($"Line {lineNumber}: SourceName must not be blank.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.Add(new NativeAlarmSourceOverrideCsvRow(
|
||||||
|
sourceName,
|
||||||
|
NullIfEmpty(fields[1]),
|
||||||
|
NullIfEmpty(fields[2]),
|
||||||
|
NullIfEmpty(fields[3]),
|
||||||
|
lineNumber));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headerSeen)
|
||||||
|
errors.Add(HeaderError);
|
||||||
|
|
||||||
|
return new NativeAlarmSourceOverrideCsvParseResult(rows, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Matches the required 4-column header (case-insensitive).</summary>
|
||||||
|
private static bool TryMatchHeader(IReadOnlyList<string> fields) =>
|
||||||
|
fields.Count == ExpectedColumns &&
|
||||||
|
HeaderEquals(fields[0], "SourceName") &&
|
||||||
|
HeaderEquals(fields[1], "Connection") &&
|
||||||
|
HeaderEquals(fields[2], "SourceReference") &&
|
||||||
|
HeaderEquals(fields[3], "Filter");
|
||||||
|
|
||||||
|
private static bool HeaderEquals(string field, string expected) =>
|
||||||
|
string.Equals(field, expected, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string? NullIfEmpty(string field) => field.Length == 0 ? null : field;
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ public static class OverrideCsvParser
|
|||||||
if (string.IsNullOrWhiteSpace(rawLine))
|
if (string.IsNullOrWhiteSpace(rawLine))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (!SplitFields(rawLine, out var fields))
|
if (!CsvLineSplitter.TrySplit(rawLine, out var fields))
|
||||||
{
|
{
|
||||||
errors.Add($"Line {lineNumber}: Unterminated quoted field.");
|
errors.Add($"Line {lineNumber}: Unterminated quoted field.");
|
||||||
continue;
|
continue;
|
||||||
@@ -134,101 +134,4 @@ public static class OverrideCsvParser
|
|||||||
string.Equals(field, expected, StringComparison.OrdinalIgnoreCase);
|
string.Equals(field, expected, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static string? NullIfEmpty(string field) => field.Length == 0 ? null : field;
|
private static string? NullIfEmpty(string field) => field.Length == 0 ? null : field;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// RFC-4180-ish field splitter for a single physical line. Quoting rules:
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>A field is <i>quoted</i> only if its first non-whitespace char is a
|
|
||||||
/// <c>"</c> (leading whitespace before the opening quote is allowed and
|
|
||||||
/// ignored). Inside a quoted field, commas are literal and <c>""</c> is an
|
|
||||||
/// escaped single quote; the closing <c>"</c> must be the last non-whitespace
|
|
||||||
/// char of the field (trailing whitespace after the close is allowed and
|
|
||||||
/// ignored).</item>
|
|
||||||
/// <item>A <c>"</c> appearing anywhere else in an unquoted field (i.e. after
|
|
||||||
/// non-whitespace content) is a <b>literal</b> character and is preserved.</item>
|
|
||||||
/// <item>Unquoted fields are whitespace-trimmed; quoted field values are kept
|
|
||||||
/// verbatim.</item>
|
|
||||||
/// </list>
|
|
||||||
/// Returns <c>true</c> with the split <paramref name="fields"/> on success;
|
|
||||||
/// returns <c>false</c> when a quoted field is opened but never closed before
|
|
||||||
/// end-of-line (the caller emits a per-line "unterminated" error).
|
|
||||||
/// </summary>
|
|
||||||
private static bool SplitFields(string line, out List<string> fields)
|
|
||||||
{
|
|
||||||
fields = new List<string>();
|
|
||||||
var field = new System.Text.StringBuilder();
|
|
||||||
var inQuotes = false; // currently between an opening and closing quote
|
|
||||||
var quoted = false; // this field opened with a quote → keep value verbatim
|
|
||||||
var sawContent = false; // any non-whitespace char seen in the current field yet
|
|
||||||
|
|
||||||
for (var i = 0; i < line.Length; i++)
|
|
||||||
{
|
|
||||||
var c = line[i];
|
|
||||||
|
|
||||||
if (inQuotes)
|
|
||||||
{
|
|
||||||
if (c == '"')
|
|
||||||
{
|
|
||||||
// Doubled quote inside a quoted field → a single literal quote.
|
|
||||||
if (i + 1 < line.Length && line[i + 1] == '"')
|
|
||||||
{
|
|
||||||
field.Append('"');
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
inQuotes = false; // closing quote; only trailing whitespace may follow
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
field.Append(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (c)
|
|
||||||
{
|
|
||||||
case ',':
|
|
||||||
fields.Add(Finalize(field, quoted));
|
|
||||||
field.Clear();
|
|
||||||
inQuotes = false;
|
|
||||||
quoted = false;
|
|
||||||
sawContent = false;
|
|
||||||
break;
|
|
||||||
case '"' when !sawContent:
|
|
||||||
// Opening quote: first non-whitespace char of the field. Any
|
|
||||||
// leading whitespace seen so far is part of the (ignored) prefix.
|
|
||||||
field.Clear();
|
|
||||||
inQuotes = true;
|
|
||||||
quoted = true;
|
|
||||||
sawContent = true;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// After a quoted field has closed, only whitespace may appear
|
|
||||||
// before the next delimiter — it is ignored, not appended.
|
|
||||||
if (quoted)
|
|
||||||
break;
|
|
||||||
|
|
||||||
// A '"' here (sawContent already true) falls through as a literal.
|
|
||||||
if (!char.IsWhiteSpace(c))
|
|
||||||
sawContent = true;
|
|
||||||
field.Append(c);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inQuotes)
|
|
||||||
return false; // opened a quoted field that was never closed
|
|
||||||
|
|
||||||
fields.Add(Finalize(field, quoted));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Finalize(System.Text.StringBuilder field, bool quoted)
|
|
||||||
{
|
|
||||||
var text = field.ToString();
|
|
||||||
return quoted ? text : text.Trim(); // only unquoted whitespace is trimmed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -270,6 +270,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
|
||||||
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
|
or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
|
||||||
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
|
or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
|
||||||
|
or SetInstanceNativeAlarmSourceOverridesCommand
|
||||||
or GetDeploymentDiffCommand
|
or GetDeploymentDiffCommand
|
||||||
or MgmtDeployArtifactsCommand
|
or MgmtDeployArtifactsCommand
|
||||||
or QueryDeploymentsCommand
|
or QueryDeploymentsCommand
|
||||||
@@ -347,6 +348,7 @@ public class ManagementActor : ReceiveActor
|
|||||||
DeleteInstanceAlarmOverrideCommand cmd => await HandleDeleteInstanceAlarmOverride(sp, cmd, user),
|
DeleteInstanceAlarmOverrideCommand cmd => await HandleDeleteInstanceAlarmOverride(sp, cmd, user),
|
||||||
ListInstanceAlarmOverridesCommand cmd => await HandleListInstanceAlarmOverrides(sp, cmd, user),
|
ListInstanceAlarmOverridesCommand cmd => await HandleListInstanceAlarmOverrides(sp, cmd, user),
|
||||||
SetInstanceNativeAlarmSourceOverrideCommand cmd => await HandleSetInstanceNativeAlarmSourceOverride(sp, cmd, user),
|
SetInstanceNativeAlarmSourceOverrideCommand cmd => await HandleSetInstanceNativeAlarmSourceOverride(sp, cmd, user),
|
||||||
|
SetInstanceNativeAlarmSourceOverridesCommand cmd => await HandleSetInstanceNativeAlarmSourceOverrides(sp, cmd, user),
|
||||||
DeleteInstanceNativeAlarmSourceOverrideCommand cmd => await HandleDeleteInstanceNativeAlarmSourceOverride(sp, cmd, user),
|
DeleteInstanceNativeAlarmSourceOverrideCommand cmd => await HandleDeleteInstanceNativeAlarmSourceOverride(sp, cmd, user),
|
||||||
ListInstanceNativeAlarmSourceOverridesCommand cmd => await HandleListInstanceNativeAlarmSourceOverrides(sp, cmd, user),
|
ListInstanceNativeAlarmSourceOverridesCommand cmd => await HandleListInstanceNativeAlarmSourceOverrides(sp, cmd, user),
|
||||||
|
|
||||||
@@ -998,6 +1000,78 @@ public class ManagementActor : ReceiveActor
|
|||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<object?> HandleSetInstanceNativeAlarmSourceOverrides(
|
||||||
|
IServiceProvider sp, SetInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
|
||||||
|
{
|
||||||
|
await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
|
||||||
|
var repo = sp.GetRequiredService<ITemplateEngineRepository>();
|
||||||
|
|
||||||
|
// All-or-nothing (parity with HandleSetInstanceOverrides). Flatten the
|
||||||
|
// instance ONCE (script compilation skipped — a lock/resolve check does not
|
||||||
|
// need it) and validate EVERY requested source up front: a source that does
|
||||||
|
// not resolve would create a dead override the flattener silently drops, and a
|
||||||
|
// template-locked source cannot be overridden. Only once the whole batch is
|
||||||
|
// known valid do we upsert — a mid-batch reject never leaves partial state.
|
||||||
|
var pipeline = sp.GetRequiredService<IFlatteningPipeline>();
|
||||||
|
var flattenResult = await pipeline.FlattenAndValidateAsync(
|
||||||
|
cmd.InstanceId, default, validateScripts: false);
|
||||||
|
if (flattenResult.IsFailure)
|
||||||
|
throw new ManagementCommandException(
|
||||||
|
$"Cannot set native alarm source overrides: the instance could not be flattened ({flattenResult.Error}).");
|
||||||
|
|
||||||
|
var resolvedByName = flattenResult.Value.Configuration.NativeAlarmSources
|
||||||
|
.ToDictionary(s => s.CanonicalName, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
// Reject a batch that names the same source twice — the intended override
|
||||||
|
// would be ambiguous, and the last-writer-wins upsert would silently discard
|
||||||
|
// the earlier row.
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var entry in cmd.Overrides)
|
||||||
|
{
|
||||||
|
if (!resolvedByName.TryGetValue(entry.SourceCanonicalName, out var resolvedSource))
|
||||||
|
throw new ManagementCommandException(
|
||||||
|
$"Native alarm source '{entry.SourceCanonicalName}' does not resolve for this instance " +
|
||||||
|
"and cannot be overridden. No overrides were applied.");
|
||||||
|
if (resolvedSource.IsLocked)
|
||||||
|
throw new ManagementCommandException(
|
||||||
|
$"Native alarm source '{entry.SourceCanonicalName}' is locked at the template level and " +
|
||||||
|
"cannot be overridden. No overrides were applied.");
|
||||||
|
if (!seen.Add(entry.SourceCanonicalName))
|
||||||
|
throw new ManagementCommandException(
|
||||||
|
$"Native alarm source '{entry.SourceCanonicalName}' appears more than once in the batch. " +
|
||||||
|
"No overrides were applied.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = new List<InstanceNativeAlarmSourceOverride>();
|
||||||
|
foreach (var entry in cmd.Overrides)
|
||||||
|
{
|
||||||
|
var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, entry.SourceCanonicalName);
|
||||||
|
if (existing == null)
|
||||||
|
{
|
||||||
|
var ovr = new InstanceNativeAlarmSourceOverride(entry.SourceCanonicalName)
|
||||||
|
{
|
||||||
|
InstanceId = cmd.InstanceId,
|
||||||
|
ConnectionNameOverride = entry.ConnectionNameOverride,
|
||||||
|
SourceReferenceOverride = entry.SourceReferenceOverride,
|
||||||
|
ConditionFilterOverride = entry.ConditionFilterOverride
|
||||||
|
};
|
||||||
|
await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
|
||||||
|
results.Add(ovr);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existing.ConnectionNameOverride = entry.ConnectionNameOverride;
|
||||||
|
existing.SourceReferenceOverride = entry.SourceReferenceOverride;
|
||||||
|
existing.ConditionFilterOverride = entry.ConditionFilterOverride;
|
||||||
|
await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
|
||||||
|
results.Add(existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.SaveChangesAsync();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<object?> HandleDeleteInstanceNativeAlarmSourceOverride(
|
private static async Task<object?> HandleDeleteInstanceNativeAlarmSourceOverride(
|
||||||
IServiceProvider sp, DeleteInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
|
IServiceProvider sp, DeleteInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -403,7 +403,9 @@ public class ScriptRuntimeContext
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Quality-agnostic by default (spec §4.2):</b> a value arriving at Bad
|
/// <b>Quality-agnostic by default (spec §4.2):</b> a value arriving at Bad
|
||||||
/// quality still satisfies the wait — the match tests the value, not the quality.
|
/// quality still satisfies the wait — the match tests the value, not the quality.
|
||||||
/// A quality-gated ("Good"-only) mode is a planned enhancement, deferred per spec §4.2.
|
/// Opt into a quality-gated ("Good"-only) wait via the <c>requireGoodQuality</c>
|
||||||
|
/// argument (spec §4.2); the InstanceActor then holds the wait until the value
|
||||||
|
/// satisfies the test at Good quality (or times out).
|
||||||
/// </para>
|
/// </para>
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
|
|||||||
@@ -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);
|
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
|
// MoveDataConnectionCommand (M9 / T24a) — guarded cross-site move
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ public class RequiredRoleMatrixTests
|
|||||||
["SetInstanceAlarmOverride"] = [Roles.Deployer],
|
["SetInstanceAlarmOverride"] = [Roles.Deployer],
|
||||||
["DeleteInstanceAlarmOverride"] = [Roles.Deployer],
|
["DeleteInstanceAlarmOverride"] = [Roles.Deployer],
|
||||||
["SetInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
|
["SetInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
|
||||||
|
["SetInstanceNativeAlarmSourceOverrides"] = [Roles.Deployer],
|
||||||
["DeleteInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
|
["DeleteInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
|
||||||
["GetDeploymentDiff"] = [Roles.Deployer],
|
["GetDeploymentDiff"] = [Roles.Deployer],
|
||||||
["MgmtDeployArtifacts"] = [Roles.Deployer],
|
["MgmtDeployArtifacts"] = [Roles.Deployer],
|
||||||
|
|||||||
Reference in New Issue
Block a user