6bd1fd3072
AccessorMirrorPairs rows now carry an ignoredRuntimeMethods list. An entry must exist on the runtime accessor and must be ABSENT from the mirror, so a stale or over-broad exclusion fails the theory. The (ScriptLogger, CompileLog) pair joins the guarded set with FlushDropped excluded (run-end flush called by ScriptRunLauncher, not script-reachable). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
265 lines
13 KiB
C#
265 lines
13 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>
|
|
/// 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
|
|
{
|
|
/// <summary>Verifies that the script compile surface exposes every member name of the runtime script globals.</summary>
|
|
[Fact]
|
|
public void ScriptCompileSurface_MemberNames_AreSupersetOf_ScriptGlobals()
|
|
{
|
|
AssertSurfaceCoversGlobals(
|
|
surface: typeof(ScriptCompileSurface),
|
|
globals: typeof(ScriptGlobals));
|
|
}
|
|
|
|
/// <summary>Verifies that the trigger compile surface exposes every member name of the trigger expression globals.</summary>
|
|
[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>
|
|
/// <param name="runtimeAccessor">The runtime script accessor type whose public methods are enumerated.</param>
|
|
/// <param name="compileMirror">The compile-only mirror type that must cover those methods.</param>
|
|
/// <param name="ignoredRuntimeMethods">
|
|
/// Runtime method names that are public but NOT script-reachable and must therefore be absent
|
|
/// from the mirror (e.g. <c>ScriptLogger.FlushDropped</c>, called by the run launcher). Each
|
|
/// entry must exist on the runtime accessor and must NOT exist on the mirror, so a stale or
|
|
/// over-broad exclusion fails the theory instead of silently widening the hole (Gitea #57).
|
|
/// </param>
|
|
[Theory]
|
|
[MemberData(nameof(AccessorMirrorPairs))]
|
|
public void CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities(
|
|
Type runtimeAccessor, Type compileMirror, string[] ignoredRuntimeMethods)
|
|
{
|
|
var ignored = new HashSet<string>(ignoredRuntimeMethods, StringComparer.Ordinal);
|
|
var mirrorCounts = PublicInstanceMethodArityCounts(compileMirror);
|
|
var runtimeCounts = PublicInstanceMethodArityCounts(runtimeAccessor);
|
|
|
|
var staleExclusions = ignored
|
|
.Where(name => !runtimeCounts.Keys.Any(k => k.Name == name))
|
|
.OrderBy(name => name, StringComparer.Ordinal)
|
|
.ToList();
|
|
Assert.True(
|
|
staleExclusions.Count == 0,
|
|
$"Exclusion(s) {string.Join(", ", staleExclusions)} name no public method on runtime accessor "
|
|
+ $"'{runtimeAccessor.Name}' — remove the stale entry from AccessorMirrorPairs.");
|
|
|
|
var exposedExclusions = ignored
|
|
.Where(name => mirrorCounts.Keys.Any(k => k.Name == name))
|
|
.OrderBy(name => name, StringComparer.Ordinal)
|
|
.ToList();
|
|
Assert.True(
|
|
exposedExclusions.Count == 0,
|
|
$"Excluded runtime method(s) {string.Join(", ", exposedExclusions)} are present on mirror "
|
|
+ $"'{compileMirror.Name}'. An exclusion means the member is NOT script-reachable, so the "
|
|
+ "mirror must not offer it — either drop it from the mirror or drop the exclusion.");
|
|
|
|
runtimeCounts = runtimeCounts
|
|
.Where(kvp => !ignored.Contains(kvp.Key.Name))
|
|
.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
|
|
|
|
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>
|
|
/// <returns>
|
|
/// The runtime accessor, compile mirror and ignored-runtime-method triples, one per theory case.
|
|
/// </returns>
|
|
public static IEnumerable<object[]> AccessorMirrorPairs() =>
|
|
new[]
|
|
{
|
|
new object[] { typeof(AttributeAccessor), typeof(ScriptCompileSurface.CompileAttributeAccessor), NoExclusions },
|
|
new object[] { typeof(CompositionAccessor), typeof(ScriptCompileSurface.CompileCompositionAccessor), NoExclusions },
|
|
// 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), NoExclusions },
|
|
// 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), NoExclusions },
|
|
// Script file access (design 2026-09-28): a new Files.* read method cannot ship on the
|
|
// runtime accessor without its design-time mirror.
|
|
new object[] { typeof(FilesAccessor), typeof(ScriptCompileSurface.CompileFiles), NoExclusions },
|
|
// Step logs (Gitea #57, register row 116): the Log global. FlushDropped() is the run-end
|
|
// closing-row flush called by ScriptRunLauncher, not by a script, so the mirror must omit it.
|
|
new object[]
|
|
{
|
|
typeof(ScriptLogger), typeof(ScriptCompileSurface.CompileLog),
|
|
new[] { nameof(ScriptLogger.FlushDropped) },
|
|
},
|
|
};
|
|
|
|
/// <summary>Exclusion list for a row whose mirror must cover every public runtime method.</summary>
|
|
private static readonly string[] NoExclusions = Array.Empty<string>();
|
|
|
|
/// <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());
|
|
}
|