using System.Reflection; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts; namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts; /// /// M3.3: compile-surface parity guard. The shared /// / are /// compile-only stubs the design-time deploy gate binds candidate scripts /// against; they must mirror the real SiteRuntime / /// 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. /// /// /// 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. /// /// 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)); } /// /// 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 /// . Inherited members /// (ToString/GetHashCode/Equals/GetType) are excluded. Fails with the exact /// list of missing names when the surface does not cover the globals. /// 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."); } /// /// Public instance property + method member names declared on or inherited by /// , excluding the inherited /// members (ToString, GetHashCode, Equals, GetType) so only script-reachable /// API names remain. Property/method names are compared (no signatures). /// private static HashSet PublicInstanceMemberNames(Type type) { const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance; var objectMembers = new HashSet(StringComparer.Ordinal) { nameof(ToString), nameof(GetHashCode), nameof(Equals), nameof(GetType), }; var names = new HashSet(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 (objectMembers.Contains(method.Name)) continue; names.Add(method.Name); } 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()); }