diff --git a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs index 685ff5a8..df3d872f 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests/Scripts/CompileSurfaceParityTests.cs @@ -41,6 +41,68 @@ public class CompileSurfaceParityTests globals: typeof(TriggerExpressionGlobals)); } + /// + /// Deeper guard than the top-level name-superset check above: for each + /// runtime script-accessor type (, + /// , ) 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 WaitAsync overloads were present on the runtime + /// but missing from + /// , so scripts that + /// awaited them passed the design-time gate yet would have failed at the site). + /// + /// + /// Matching is by NAME + PARAMETER COUNT only — the mirror uses + /// object?-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 WaitAsync, 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. + /// + /// + [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."); + } + + /// + /// Runtime script-accessor type ↔ compile-surface mirror pairs guarded by + /// . + /// + public static IEnumerable AccessorMirrorPairs() => + new[] + { + new object[] { typeof(AttributeAccessor), typeof(ScriptCompileSurface.CompileAttributeAccessor) }, + new object[] { typeof(CompositionAccessor), typeof(ScriptCompileSurface.CompileCompositionAccessor) }, + new object[] { typeof(ChildrenAccessor), typeof(ScriptCompileSurface.CompileChildrenAccessor) }, + }; + /// /// Asserts that the public instance property + method member names of /// are a superset of those of @@ -102,4 +164,50 @@ public class CompileSurfaceParityTests return names; } + + /// + /// Names of the inherited instance methods that are not + /// part of any script-reachable accessor API and must be excluded from parity. + /// + private static readonly HashSet ObjectMethodNames = new(StringComparer.Ordinal) + { + nameof(ToString), + nameof(GetHashCode), + nameof(Equals), + nameof(GetType), + }; + + /// + /// (Name, parameter count) of each public instance METHOD declared on or + /// inherited by , excluding inherited + /// 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. + /// + 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); + } + } + + /// + /// Number of public instance method overloads on 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 + /// so the same object/special-name + /// exclusions apply on both sides. + /// + private static Dictionary<(string Name, int Arity), int> PublicInstanceMethodArityCounts(Type type) => + PublicInstanceMethodSignatures(type) + .GroupBy(sig => sig) + .ToDictionary(g => g.Key, g => g.Count()); }