feat(scripts): ScriptAlarm.IsMesReportable + Alarms.MesReportableAsync() mirrored on both compile surfaces
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
@@ -161,6 +161,18 @@ public class SandboxAlarmsAccessor
|
|||||||
"Alarms.CurrentAsync() reads the site Instance Actor's live alarm conditions, " +
|
"Alarms.CurrentAsync() reads the site Instance Actor's live alarm conditions, " +
|
||||||
"which aren't available in the central Test Run sandbox — deploy to a site to " +
|
"which aren't available in the central Test Run sandbox — deploy to a site to " +
|
||||||
"exercise alarm-reading scripts.");
|
"exercise alarm-reading scripts.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mirrors <c>AlarmsAccessor.MesReportableAsync</c> for compile parity; unsupported at
|
||||||
|
/// run time in the central Test Run sandbox.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Token used to cancel the read (unused in the sandbox).</param>
|
||||||
|
/// <returns>Never returns; always throws <see cref="ScriptSandboxException"/> in the sandbox.</returns>
|
||||||
|
public Task<IReadOnlyList<ScriptAlarm>> MesReportableAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> throw new ScriptSandboxException(
|
||||||
|
"Alarms.MesReportableAsync() reads the site Instance Actor's live alarm conditions, " +
|
||||||
|
"which aren't available in the central Test Run sandbox — deploy to a site to " +
|
||||||
|
"exercise alarm-reading scripts.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
|
|||||||
/// True for a placeholder row standing in for a CONFIGURED native source binding that
|
/// True for a placeholder row standing in for a CONFIGURED native source binding that
|
||||||
/// currently holds no conditions. Scripts enumerating real alarms should filter these out.
|
/// currently holds no conditions. Scripts enumerating real alarms should filter these out.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="IsMesReportable">
|
||||||
|
/// True when the deployed configuration flags the alarm as MES-reportable (template
|
||||||
|
/// checkbox or instance override). Always false for native conditions.
|
||||||
|
/// </param>
|
||||||
public sealed record ScriptAlarm(
|
public sealed record ScriptAlarm(
|
||||||
string Name,
|
string Name,
|
||||||
string SourceReference,
|
string SourceReference,
|
||||||
@@ -76,4 +80,5 @@ public sealed record ScriptAlarm(
|
|||||||
DateTimeOffset? AckTime,
|
DateTimeOffset? AckTime,
|
||||||
string CurrentValue,
|
string CurrentValue,
|
||||||
string LimitValue,
|
string LimitValue,
|
||||||
bool IsConfiguredPlaceholder);
|
bool IsConfiguredPlaceholder,
|
||||||
|
bool IsMesReportable = false);
|
||||||
|
|||||||
@@ -154,6 +154,12 @@ public sealed class ScriptCompileSurface
|
|||||||
/// <returns>The instance's current alarm conditions.</returns>
|
/// <returns>The instance's current alarm conditions.</returns>
|
||||||
public Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken cancellationToken = default)
|
public Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken cancellationToken = default)
|
||||||
=> throw new NotSupportedException(CompileOnly);
|
=> throw new NotSupportedException(CompileOnly);
|
||||||
|
|
||||||
|
/// <summary>Mirrors <c>AlarmsAccessor.MesReportableAsync</c>.</summary>
|
||||||
|
/// <param name="cancellationToken">Token used to cancel the read.</param>
|
||||||
|
/// <returns>The MES-reportable subset of the instance's current alarm conditions.</returns>
|
||||||
|
public Task<IReadOnlyList<ScriptAlarm>> MesReportableAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> throw new NotSupportedException(CompileOnly);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.ExternalSystemHelper</c>.</summary>
|
/// <summary>Compile-only mirror of <c>ScriptRuntimeContext.ExternalSystemHelper</c>.</summary>
|
||||||
|
|||||||
@@ -398,6 +398,18 @@ public class AlarmsAccessor
|
|||||||
/// <returns>The instance's current alarm conditions; empty when it holds none.</returns>
|
/// <returns>The instance's current alarm conditions; empty when it holds none.</returns>
|
||||||
public Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken cancellationToken = default)
|
public Task<IReadOnlyList<ScriptAlarm>> CurrentAsync(CancellationToken cancellationToken = default)
|
||||||
=> _ctx.GetAlarmsAsync(cancellationToken);
|
=> _ctx.GetAlarmsAsync(cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="CurrentAsync"/> restricted to alarms whose deployed configuration flags them
|
||||||
|
/// MES-reportable (the template checkbox, or an instance override). Only computed alarms
|
||||||
|
/// can carry the flag — the Instance Actor stamps it from the deployed config — so native
|
||||||
|
/// conditions and their placeholder rows are never included. Callers still filter on
|
||||||
|
/// <c>Active</c> to get only triggered alarms.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">Cancels the wait for the actor's reply.</param>
|
||||||
|
/// <returns>The MES-reportable subset of the instance's current alarm conditions.</returns>
|
||||||
|
public Task<IReadOnlyList<ScriptAlarm>> MesReportableAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> _ctx.GetMesReportableAlarmsAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static class ScopeAccessorFactory
|
internal static class ScopeAccessorFactory
|
||||||
|
|||||||
@@ -1272,6 +1272,12 @@ public class ScriptRuntimeContext
|
|||||||
return response.Alarms.Select(ToScriptAlarm).ToList();
|
return response.Alarms.Select(ToScriptAlarm).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary><see cref="GetAlarmsAsync"/> restricted to rows flagged MES-reportable.</summary>
|
||||||
|
/// <param name="cancellationToken">Cancels the wait for the actor's reply.</param>
|
||||||
|
/// <returns>The MES-reportable subset of the instance's current alarm conditions.</returns>
|
||||||
|
internal async Task<IReadOnlyList<ScriptAlarm>> GetMesReportableAlarmsAsync(CancellationToken cancellationToken = default)
|
||||||
|
=> (await GetAlarmsAsync(cancellationToken)).Where(a => a.IsMesReportable).ToList();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Projects a retained <see cref="AlarmStateChanged"/> onto the flat, script-facing
|
/// Projects a retained <see cref="AlarmStateChanged"/> onto the flat, script-facing
|
||||||
/// <see cref="ScriptAlarm"/> shape. <c>Condition</c> is the authority for the
|
/// <see cref="ScriptAlarm"/> shape. <c>Condition</c> is the authority for the
|
||||||
@@ -1301,7 +1307,8 @@ public class ScriptRuntimeContext
|
|||||||
AckTime: a.AckTime,
|
AckTime: a.AckTime,
|
||||||
CurrentValue: a.CurrentValue,
|
CurrentValue: a.CurrentValue,
|
||||||
LimitValue: a.LimitValue,
|
LimitValue: a.LimitValue,
|
||||||
IsConfiguredPlaceholder: a.IsConfiguredPlaceholder);
|
IsConfiguredPlaceholder: a.IsConfiguredPlaceholder,
|
||||||
|
IsMesReportable: a.IsMesReportable);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Site-local tracking-status API for cached operations.
|
/// Site-local tracking-status API for cached operations.
|
||||||
|
|||||||
+3
-1
@@ -819,7 +819,9 @@ public class ScriptAnalysisServiceTests
|
|||||||
" AckDT = a.AckTime,\n" +
|
" AckDT = a.AckTime,\n" +
|
||||||
" AckComment = a.OperatorComment })\n" +
|
" AckComment = a.OperatorComment })\n" +
|
||||||
" .ToList();\n" +
|
" .ToList();\n" +
|
||||||
"return new { WasSuccessful = true, Alarms = rows };";
|
"var r = await Alarms.MesReportableAsync();\n" +
|
||||||
|
"var f = r.Where(a => a.IsMesReportable).ToList();\n" +
|
||||||
|
"return new { WasSuccessful = true, Alarms = rows, Flagged = f.Count };";
|
||||||
var resp = _svc.Diagnose(new DiagnoseRequest(code));
|
var resp = _svc.Diagnose(new DiagnoseRequest(code));
|
||||||
|
|
||||||
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
|
Assert.DoesNotContain(resp.Markers, m => m.Code.StartsWith("CS"));
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ public class RoslynScriptCompilerTests
|
|||||||
var scope = Scope;
|
var scope = Scope;
|
||||||
var currentAlarms = await Alarms.CurrentAsync();
|
var currentAlarms = await Alarms.CurrentAsync();
|
||||||
var viaInstance = await Instance.Alarms.CurrentAsync();
|
var viaInstance = await Instance.Alarms.CurrentAsync();
|
||||||
|
var mesReportable = await Alarms.MesReportableAsync();
|
||||||
|
var flaggedAlarms = mesReportable.Where(a => a.IsMesReportable).ToList();
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
var diagnostics = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
|
||||||
@@ -101,7 +103,12 @@ public class RoslynScriptCompilerTests
|
|||||||
AckComment = a.OperatorComment,
|
AckComment = a.OperatorComment,
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos };
|
// The pre-filtered accessor + the configured flag are the supported shape for
|
||||||
|
// "which alarms does MES care about?" — both must bind at design time.
|
||||||
|
var r = await Alarms.MesReportableAsync();
|
||||||
|
var f = r.Where(a => a.IsMesReportable).ToList();
|
||||||
|
|
||||||
|
return new { WasSuccessful = true, ErrorText = (string)null, Alarms = infos, Flagged = f.Count };
|
||||||
""";
|
""";
|
||||||
|
|
||||||
Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
|
Assert.Empty(RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface)));
|
||||||
|
|||||||
@@ -187,6 +187,43 @@ public class AlarmsAccessorTests : TestKit, IDisposable
|
|||||||
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", real.Name);
|
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", real.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CurrentAsync_ProjectsIsMesReportable()
|
||||||
|
{
|
||||||
|
// The deployed configuration's MES-reportable flag rides the snapshot row and must
|
||||||
|
// reach the script projection verbatim — a row without it reads false.
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var ctx = MakeContext(probe.Ref);
|
||||||
|
|
||||||
|
var pending = ctx.Alarms.CurrentAsync();
|
||||||
|
RespondWith(probe,
|
||||||
|
NativeAcked() with { IsMesReportable = true },
|
||||||
|
new AlarmStateChanged("CvdReactor01", "HighTemp", AlarmState.Active, 700, ObservedAt));
|
||||||
|
|
||||||
|
var alarms = await pending;
|
||||||
|
Assert.Equal(2, alarms.Count);
|
||||||
|
Assert.True(Assert.Single(alarms, a => a.Name == "Z28061.HeartbeatTimeoutAlarm").IsMesReportable);
|
||||||
|
Assert.False(Assert.Single(alarms, a => a.Name == "HighTemp").IsMesReportable);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MesReportableAsync_FiltersToFlaggedRows()
|
||||||
|
{
|
||||||
|
// The convenience accessor is CurrentAsync restricted to the flagged subset, so a
|
||||||
|
// script does not have to repeat the filter (and cannot get it subtly wrong).
|
||||||
|
var probe = CreateTestProbe();
|
||||||
|
var ctx = MakeContext(probe.Ref);
|
||||||
|
|
||||||
|
var pending = ctx.Alarms.MesReportableAsync();
|
||||||
|
RespondWith(probe,
|
||||||
|
NativeAcked() with { IsMesReportable = true },
|
||||||
|
NativeAcked() with { AlarmName = "Z28061.UnflaggedAlarm", SourceReference = "Z28061.UnflaggedAlarm" });
|
||||||
|
|
||||||
|
var alarm = Assert.Single(await pending);
|
||||||
|
Assert.Equal("Z28061.HeartbeatTimeoutAlarm", alarm.Name);
|
||||||
|
Assert.True(alarm.IsMesReportable);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CurrentAsync_IsNotScopePrefixed_EveryScopeSeesTheSameList()
|
public async Task CurrentAsync_IsNotScopePrefixed_EveryScopeSeesTheSameList()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user