review(wave-b): close SA {{equip}} authoring gap (M1) + harden parity tests (L3)

MEDIUM-1: the editor<->authoring<->deploy invariant now holds on the ScriptedAlarm
surface too. CreateScriptedAlarmAsync/UpdateScriptedAlarmAsync validate {{equip}}/<RefName>
in BOTH the predicate script and the message template (via ExtractEquipReferenceNamesFromText),
matching what DraftValidator already enforces at deploy. Refactored ValidateEquipTokenAsync
into shared LoadEquipReferenceNamesAsync + CheckEquipReferencesResolve helpers.

LOW-1: LoadEquipReferenceNamesAsync uses IsNullOrEmpty (defense-in-depth) to match
EquipmentReferenceMap.Build; empty override is already normalized->null at the write path.

LOW-3: parity test hardened with two edge cases — unresolved-ref-left-intact (both seams
leave {{equip}}/X identical) and folder+tag-group RawPath ancestry.

Tests: VirtualTagEquipTokenValidationTests 9/9 (+3 SA), parity 4/4 (+2). AdminUI 659/0.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 07:18:59 -04:00
parent 29bb4f176e
commit 96af69e3d2
3 changed files with 174 additions and 2 deletions
@@ -1241,8 +1241,37 @@ public sealed class UnsTreeService(
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
if (used.Count == 0) return null;
var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
}
// The equipment's reference effective-name set: DisplayNameOverride else the backing raw tag's Name.
/// <summary>
/// Scripted-alarm variant of <see cref="ValidateEquipTokenAsync"/>: validates <c>{{equip}}/&lt;RefName&gt;</c>
/// tokens in BOTH the predicate script AND the message template against the equipment's reference set —
/// the authoring mirror of the deploy gate, which covers all three (VT scripts, SA predicates, SA templates).
/// Keeps the editor⇔authoring⇔deploy invariant tight on the SA surface.
/// </summary>
private static async Task<UnsMutationResult?> ValidateEquipTokenForAlarmAsync(
OtOpcUaConfigDbContext db, string equipmentId, string? predicateScriptId, string? messageTemplate, CancellationToken ct)
{
var used = new HashSet<string>(StringComparer.Ordinal);
if (!string.IsNullOrEmpty(predicateScriptId))
{
var src = await db.Scripts.Where(s => s.ScriptId == predicateScriptId)
.Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNames(src)) used.Add(u);
}
foreach (var u in EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(messageTemplate)) used.Add(u);
if (used.Count == 0) return null;
var effectiveNames = await LoadEquipReferenceNamesAsync(db, equipmentId, ct);
return CheckEquipReferencesResolve(used, effectiveNames, equipmentId);
}
/// <summary>The equipment's reference effective-name set: <c>DisplayNameOverride</c> else the backing raw
/// tag's <c>Name</c> (empty override treated as absent, matching <c>EquipmentReferenceMap.Build</c>), ordinal.</summary>
private static async Task<HashSet<string>> LoadEquipReferenceNamesAsync(
OtOpcUaConfigDbContext db, string equipmentId, CancellationToken ct)
{
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.TagId, r.DisplayNameOverride })
@@ -1255,10 +1284,18 @@ public sealed class UnsTreeService(
var effectiveNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in refs)
{
var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
var eff = string.IsNullOrEmpty(r.DisplayNameOverride)
? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null)
: r.DisplayNameOverride;
if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!);
}
return effectiveNames;
}
/// <summary>Names the unresolved <c>{{equip}}/&lt;RefName&gt;</c> tokens (or null when all resolve).</summary>
private static UnsMutationResult? CheckEquipReferencesResolve(
IReadOnlyCollection<string> used, HashSet<string> effectiveNames, string equipmentId)
{
var missing = used.Where(u => !effectiveNames.Contains(u)).ToList();
if (missing.Count == 0) return null;
return new UnsMutationResult(false,
@@ -1614,6 +1651,10 @@ public sealed class UnsTreeService(
var nameGuard = await _guard.CheckAsync(equipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, null, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, equipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
if (equipGuard is not null) return equipGuard.Value;
db.ScriptedAlarms.Add(new ScriptedAlarm
{
ScriptedAlarmId = input.ScriptedAlarmId,
@@ -1648,6 +1689,10 @@ public sealed class UnsTreeService(
var nameGuard = await _guard.CheckAsync(entity.EquipmentId, input.Name, EffectiveNameSourceKind.ScriptedAlarm, scriptedAlarmId, ct);
if (nameGuard is not null) return new UnsMutationResult(false, nameGuard);
// v3 Batch 3 (WP4): {{equip}}/<RefName> in the predicate script or message template must resolve.
var equipGuard = await ValidateEquipTokenForAlarmAsync(db, entity.EquipmentId, input.PredicateScriptId, input.MessageTemplate, ct);
if (equipGuard is not null) return equipGuard.Value;
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
entity.Name = input.Name;
entity.AlarmType = input.AlarmType;