d0af884760
MES alarm-status API §5.2 (docs/plans/2026-06-30-mes-alarm-status-api.md,
Phase 1 tasks 2-4). Site `Call` scripts had NO way to read alarm condition
state: the `Alarm` global exists only inside an on-trigger handler and
describes the one alarm that fired, and native mirrored conditions were
reachable only from the Debug View. That gap blocked the CvdReactor
SimpleAlarmStatus/AlarmStatus scripts entirely -- they cannot be written
without it. `Alarms.CurrentAsync()` closes it.
The data was already local: the script runs inside its own Instance Actor's
context, so this is a LOCAL Ask -- the same mechanism attribute reads use, no
cross-cluster hop. A dedicated GetAlarmSnapshotRequest/Response is used rather
than reusing DebugSnapshotRequest, which would materialise every attribute
value on every alarm poll; both are served from the same
BuildAlarmStatesSnapshot(), so the script view and the operator's Debug View
can never disagree.
Deliberate shape decisions:
- NOT scope-prefixed, unlike Attributes. Alarm identity is not a
scope-relative attribute name (computed alarms are keyed by configured
name, native conditions by a source-supplied reference), so prefixing
would hand a composed script a silently truncated list.
- Read-only. Native alarms are a read-only mirror of the source (no
ack-back), so no acknowledge/shelve operation is exposed.
- Placeholder rows are NOT pre-filtered: a caller must be able to tell
"binding configured and quiet" from "binding unknown". The documented
filter is `Active && !IsConfiguredPlaceholder`.
- ScriptAlarm lives in Commons so the runtime accessor and the compile-only
surface project to the SAME type -- a script binding at the design-time
gate binds identically at the site. Condition is the authority for
active/acked/severity, so one filter expression works across computed and
native alarms.
Mirrored on BOTH design-time surfaces. ScriptCompileSurface is covered by the
reflection parity guard (AlarmsAccessor added to its mirror pairs). The Central
UI Test-Run SandboxScriptHost is the third, hand-maintained mirror that the
parity test cannot reach (Central UI does not reference Site Runtime); without
it the design page would false-flag CS1061 on scripts the deploy gate accepts.
It throws a labelled ScriptSandboxException at run time -- there is no central
route to per-instance alarm state, and returning an empty list would read as
"nothing is in alarm", which is worse than an error.
ScriptTrustPolicy needs NO change, and the reason is structural rather than
incidental: the trust boundary is a deny-list over API roots, not an allow-list
of context members. A test pins that no ForbiddenScopes entry prefixes the
Commons script-surface namespace, so a future deny-list entry cannot silently
make ScriptAlarm untouchable.
Tests: 6 accessor cases (Ask contract, full native projection incl. AckTime,
unacked, computed-alarm derivation, placeholder visibility, scope-independence),
2 InstanceActor snapshot cases incl. equality with the Debug View row set, the
full MES script shape compiling against ScriptCompileSurface, 2 trust cases,
and a sandbox diagnose-clean case reading every projected ScriptAlarm field.
215 lines
10 KiB
C#
215 lines
10 KiB
C#
using System.Reflection;
|
|
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
|
|
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
|
|
|
|
/// <summary>
|
|
/// M3.3: compile-surface parity guard. The shared
|
|
/// <see cref="ScriptCompileSurface"/> / <see cref="TriggerCompileSurface"/> are
|
|
/// compile-only stubs the design-time deploy gate binds candidate scripts
|
|
/// against; they must mirror the real SiteRuntime <see cref="ScriptGlobals"/> /
|
|
/// <see cref="TriggerExpressionGlobals"/> bind surfaces. If a member a script can
|
|
/// reference on the real globals is missing from the compile surface, a script
|
|
/// that uses it would pass the design-time gate but fail at the site — so the
|
|
/// compile surface's public top-level member NAMES must be a SUPERSET of the
|
|
/// real globals' names. This test fails loudly (listing the missing names) when
|
|
/// the surface drifts behind the runtime globals.
|
|
///
|
|
/// <para>
|
|
/// Top-level member-name parity is sufficient: the design-time compile against
|
|
/// the surface catches deeper signature mismatches itself; this guard only
|
|
/// ensures every entry point a script can name on the real globals exists on the
|
|
/// stub.
|
|
/// </para>
|
|
/// </summary>
|
|
public class CompileSurfaceParityTests
|
|
{
|
|
[Fact]
|
|
public void ScriptCompileSurface_MemberNames_AreSupersetOf_ScriptGlobals()
|
|
{
|
|
AssertSurfaceCoversGlobals(
|
|
surface: typeof(ScriptCompileSurface),
|
|
globals: typeof(ScriptGlobals));
|
|
}
|
|
|
|
[Fact]
|
|
public void TriggerCompileSurface_MemberNames_AreSupersetOf_TriggerExpressionGlobals()
|
|
{
|
|
AssertSurfaceCoversGlobals(
|
|
surface: typeof(TriggerCompileSurface),
|
|
globals: typeof(TriggerExpressionGlobals));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deeper guard than the top-level name-superset check above: for each
|
|
/// runtime script-accessor type (<see cref="AttributeAccessor"/>,
|
|
/// <see cref="CompositionAccessor"/>, <see cref="ChildrenAccessor"/>) every
|
|
/// public instance METHOD must have a same-name / same-arity counterpart on
|
|
/// its compile-surface mirror. This catches the regression that motivated this
|
|
/// test (the <c>WaitAsync</c> overloads were present on the runtime
|
|
/// <see cref="AttributeAccessor"/> but missing from
|
|
/// <see cref="ScriptCompileSurface.CompileAttributeAccessor"/>, so scripts that
|
|
/// awaited them passed the design-time gate yet would have failed at the site).
|
|
///
|
|
/// <para>
|
|
/// Matching is by NAME + PARAMETER COUNT only — the mirror uses
|
|
/// <c>object?</c>-vs-mirror-type substitutions, so exact parameter-type identity
|
|
/// is intentionally NOT required; the guard is about API presence, not type
|
|
/// equality (the design-time compile catches deeper signature drift itself).
|
|
/// To still catch a single dropped overload within an overload set that shares
|
|
/// an arity (e.g. the value-form vs predicate-form <c>WaitAsync</c>, both
|
|
/// 4-parameter), the assertion is by COUNT: the mirror must expose at least as
|
|
/// many overloads for each (name, arity) as the runtime accessor declares.
|
|
/// </para>
|
|
/// </summary>
|
|
[Theory]
|
|
[MemberData(nameof(AccessorMirrorPairs))]
|
|
public void CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities(
|
|
Type runtimeAccessor, Type compileMirror)
|
|
{
|
|
var mirrorCounts = PublicInstanceMethodArityCounts(compileMirror);
|
|
var runtimeCounts = PublicInstanceMethodArityCounts(runtimeAccessor);
|
|
|
|
var shortfalls = runtimeCounts
|
|
.Where(kvp => mirrorCounts.GetValueOrDefault(kvp.Key) < kvp.Value)
|
|
.OrderBy(kvp => kvp.Key.Name, StringComparer.Ordinal)
|
|
.ThenBy(kvp => kvp.Key.Arity)
|
|
.Select(kvp =>
|
|
$"{kvp.Key.Name}({kvp.Key.Arity} param(s)): runtime has {kvp.Value} "
|
|
+ $"overload(s), mirror has {mirrorCounts.GetValueOrDefault(kvp.Key)}")
|
|
.ToList();
|
|
|
|
Assert.True(
|
|
shortfalls.Count == 0,
|
|
$"Compile surface mirror '{compileMirror.Name}' under-covers {shortfalls.Count} "
|
|
+ $"method group(s) on runtime accessor '{runtimeAccessor.Name}': "
|
|
+ $"{string.Join("; ", shortfalls)}. "
|
|
+ "The compile-only mirror must expose a same-name / same-arity method for "
|
|
+ "every public instance method a script can call on the runtime accessor — "
|
|
+ "add the missing method(s) to the ScriptAnalysis compile surface so a "
|
|
+ "script using them cannot pass the design-time gate then fail at the site.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Runtime script-accessor type ↔ compile-surface mirror pairs guarded by
|
|
/// <see cref="CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities"/>.
|
|
/// </summary>
|
|
public static IEnumerable<object[]> AccessorMirrorPairs() =>
|
|
new[]
|
|
{
|
|
new object[] { typeof(AttributeAccessor), typeof(ScriptCompileSurface.CompileAttributeAccessor) },
|
|
new object[] { typeof(CompositionAccessor), typeof(ScriptCompileSurface.CompileCompositionAccessor) },
|
|
// ChildrenAccessor currently exposes only an indexer (no public instance
|
|
// methods), so this row guards nothing today. It is kept so that if a
|
|
// callable method is ever added to ChildrenAccessor, this Theory begins
|
|
// enforcing a mirror for it automatically — rather than silently
|
|
// re-opening the WaitAsync-class gap.
|
|
new object[] { typeof(ChildrenAccessor), typeof(ScriptCompileSurface.CompileChildrenAccessor) },
|
|
// MES alarm-status API §5.2: the Alarms accessor joins the guarded set so a
|
|
// future read method (e.g. an ActiveAsync convenience) cannot ship on the
|
|
// runtime accessor while the design-time gate stays blind to it.
|
|
new object[] { typeof(AlarmsAccessor), typeof(ScriptCompileSurface.CompileAlarmsAccessor) },
|
|
};
|
|
|
|
/// <summary>
|
|
/// Asserts that the public instance property + method member names of
|
|
/// <paramref name="surface"/> are a superset of those of
|
|
/// <paramref name="globals"/>. Inherited <see cref="object"/> members
|
|
/// (ToString/GetHashCode/Equals/GetType) are excluded. Fails with the exact
|
|
/// list of missing names when the surface does not cover the globals.
|
|
/// </summary>
|
|
private static void AssertSurfaceCoversGlobals(Type surface, Type globals)
|
|
{
|
|
var surfaceNames = PublicInstanceMemberNames(surface);
|
|
var globalsNames = PublicInstanceMemberNames(globals);
|
|
|
|
var missing = globalsNames
|
|
.Where(name => !surfaceNames.Contains(name))
|
|
.OrderBy(name => name, StringComparer.Ordinal)
|
|
.ToList();
|
|
|
|
Assert.True(
|
|
missing.Count == 0,
|
|
$"Compile surface '{surface.Name}' is missing {missing.Count} member name(s) "
|
|
+ $"present on '{globals.Name}': {string.Join(", ", missing)}. "
|
|
+ "The compile-only surface must mirror the runtime globals — add the "
|
|
+ "missing member(s) to the ScriptAnalysis surface type.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Public instance property + method member names declared on or inherited by
|
|
/// <paramref name="type"/>, excluding the inherited <see cref="object"/>
|
|
/// members (ToString, GetHashCode, Equals, GetType) so only script-reachable
|
|
/// API names remain. Property/method names are compared (no signatures).
|
|
/// </summary>
|
|
private static HashSet<string> PublicInstanceMemberNames(Type type)
|
|
{
|
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;
|
|
|
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
foreach (var property in type.GetProperties(flags))
|
|
names.Add(property.Name);
|
|
|
|
foreach (var method in type.GetMethods(flags))
|
|
{
|
|
// Skip compiler-generated property accessors (get_X / set_X) — the
|
|
// property itself is already counted by name above.
|
|
if (method.IsSpecialName)
|
|
continue;
|
|
if (ObjectMethodNames.Contains(method.Name))
|
|
continue;
|
|
names.Add(method.Name);
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Names of the inherited <see cref="object"/> instance methods that are not
|
|
/// part of any script-reachable accessor API and must be excluded from parity.
|
|
/// </summary>
|
|
private static readonly HashSet<string> ObjectMethodNames = new(StringComparer.Ordinal)
|
|
{
|
|
nameof(ToString),
|
|
nameof(GetHashCode),
|
|
nameof(Equals),
|
|
nameof(GetType),
|
|
};
|
|
|
|
/// <summary>
|
|
/// (Name, parameter count) of each public instance METHOD declared on or
|
|
/// inherited by <paramref name="type"/>, excluding inherited <see cref="object"/>
|
|
/// methods and compiler-generated special-name methods (property/indexer
|
|
/// get_/set_ accessors, operators). These are the methods a script can call
|
|
/// directly on the accessor, the surface this guard requires the mirror to cover.
|
|
/// </summary>
|
|
private static IEnumerable<(string Name, int Arity)> PublicInstanceMethodSignatures(Type type)
|
|
{
|
|
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;
|
|
|
|
foreach (var method in type.GetMethods(flags))
|
|
{
|
|
if (method.IsSpecialName)
|
|
continue;
|
|
if (ObjectMethodNames.Contains(method.Name))
|
|
continue;
|
|
yield return (method.Name, method.GetParameters().Length);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Number of public instance method overloads on <paramref name="type"/> per
|
|
/// (Name, parameter count) key — used to test method-arity parity against a
|
|
/// runtime accessor by count (so a single dropped overload within a shared-arity
|
|
/// overload set is caught, not just a wholly-missing method group). Built with
|
|
/// <see cref="PublicInstanceMethodSignatures"/> so the same object/special-name
|
|
/// exclusions apply on both sides.
|
|
/// </summary>
|
|
private static Dictionary<(string Name, int Arity), int> PublicInstanceMethodArityCounts(Type type) =>
|
|
PublicInstanceMethodSignatures(type)
|
|
.GroupBy(sig => sig)
|
|
.ToDictionary(g => g.Key, g => g.Count());
|
|
}
|