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:
+51
@@ -189,4 +189,55 @@ public sealed class VirtualTagEquipTokenValidationTests
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----- ScriptedAlarm (Wave-B review MEDIUM-1: the invariant must hold on the SA surface too) -----
|
||||
|
||||
private static ScriptedAlarmInput AlarmInput(string messageTemplate = "value out of range") =>
|
||||
new("ALM-1", "HighTemp", AlarmType: "Rate", Severity: 500, MessageTemplate: messageTemplate,
|
||||
PredicateScriptId: "SCRIPT-1", HistorizeToAveva: false, Retain: false, Enabled: true);
|
||||
|
||||
/// <summary>SA predicate uses {{equip}}/Speed with no matching reference → create rejected, error names the token.</summary>
|
||||
[Fact]
|
||||
public async Task Create_alarm_equip_ref_in_predicate_unresolved_rejected()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, EquipRefScript, referenceName: null);
|
||||
|
||||
var result = await service.CreateScriptedAlarmAsync("EQ-1", AlarmInput());
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("EQ-1");
|
||||
result.Error.ShouldContain("{{equip}}/Speed");
|
||||
|
||||
using var db = UnsTreeTestDb.CreateNamed(dbName);
|
||||
db.ScriptedAlarms.Any(a => a.ScriptedAlarmId == "ALM-1").ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>SA message template carries {{equip}}/Bad with no matching reference → create rejected (template scan).</summary>
|
||||
[Fact]
|
||||
public async Task Create_alarm_equip_ref_in_message_template_unresolved_rejected()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, PlainScript, referenceName: "Speed"); // predicate has no token; only the template does
|
||||
|
||||
var result = await service.CreateScriptedAlarmAsync("EQ-1", AlarmInput(messageTemplate: "bad {{equip}}/Missing here"));
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error.ShouldContain("{{equip}}/Missing");
|
||||
}
|
||||
|
||||
/// <summary>SA predicate {{equip}}/Speed with a matching reference → create succeeds.</summary>
|
||||
[Fact]
|
||||
public async Task Create_alarm_equip_ref_that_resolves_succeeds()
|
||||
{
|
||||
var (service, dbName) = Fresh();
|
||||
Seed(dbName, EquipRefScript, referenceName: "Speed");
|
||||
|
||||
var result = await service.CreateScriptedAlarmAsync("EQ-1", AlarmInput());
|
||||
|
||||
result.Ok.ShouldBeTrue();
|
||||
result.Error.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user