using ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation; namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests.Validation; /// /// WP2.5: the verdict cache's eviction policy. Overflow used to Clear() the /// whole cache, which re-opened the non-collectible InteractiveAssemblyLoader /// leak the cache exists to bound — every hot script had to be recompiled, and every /// recompile loads another assembly that can never be unloaded. These tests pin the /// replacement: eviction is segmented, so entries in active use survive it. /// /// /// Serialised with the other verdict-cache tests: the cache is process-wide static /// state, so two test classes filling it concurrently would see each other's /// entries. /// /// [Collection("ScriptCompileVerdictCache")] public class ScriptCompileVerdictCacheEvictionTests { private const string Surface = "TestSurface"; /// Entries needed to force at least one generation rotation. private const int OverflowCount = 5000; private static (bool Ok, string? Error) Lookup(string code, Func<(bool, string?)> factory) => ScriptCompileVerdictCache.GetOrAdd(Surface, code, factory); [Fact] public void Overflow_KeepsHotEntry_AndDoesNotClearEverything() { ScriptCompileVerdictCache.Clear(); const string hotCode = "// the script every deploy re-validates"; var hotCompiles = 0; (bool, string?) HotFactory() { hotCompiles++; return (true, null); } Lookup(hotCode, HotFactory); Assert.Equal(1, hotCompiles); // Push far more distinct scripts through than the cache can hold, touching // the hot entry as we go — which is exactly what a real workload does, and // exactly what wholesale Clear() used to throw away. for (var i = 0; i < OverflowCount; i++) { Lookup($"// filler {i}", static () => (true, null)); if (i % 25 == 0) Lookup(hotCode, HotFactory); } // At least one rotation happened... Assert.True(ScriptCompileVerdictCache.Evictions > 0, "the overflow did not trigger a single eviction — the test no longer exercises the policy"); // ...and the hot entry was never recompiled, because a hit in the cold // generation promotes it back into hot rather than letting it age out. Assert.Equal(1, hotCompiles); // A final read still hits. var compilesBefore = hotCompiles; Lookup(hotCode, HotFactory); Assert.Equal(compilesBefore, hotCompiles); } [Fact] public void Overflow_RetainsRecentEntries_RatherThanDroppingAll() { ScriptCompileVerdictCache.Clear(); for (var i = 0; i < OverflowCount; i++) Lookup($"// bulk {i}", static () => (true, null)); Assert.True(ScriptCompileVerdictCache.Evictions > 0); // The most recently inserted entry is in the hot generation, so it must // still be cached. Under the old Clear()-on-overflow policy the cache could // be left holding a single entry after a rotation. var recompiled = false; Lookup($"// bulk {OverflowCount - 1}", () => { recompiled = true; return (true, null); }); Assert.False(recompiled, "the most recent entry was evicted; eviction is not retaining the hot generation"); Assert.True(ScriptCompileVerdictCache.Count > 1, $"cache retained only {ScriptCompileVerdictCache.Count} entries after eviction"); } [Fact] public void Overflow_KeepsCacheBounded() { ScriptCompileVerdictCache.Clear(); for (var i = 0; i < OverflowCount; i++) Lookup($"// bounded {i}", static () => (true, null)); // Two generations of 2048 — the same 4096 ceiling the previous policy had, // now reached by demotion rather than by dropping everything. Assert.True(ScriptCompileVerdictCache.Count <= 4096, $"cache grew to {ScriptCompileVerdictCache.Count} entries, exceeding its two-generation bound"); } /// /// The bound must hold under a PROMOTION-heavy workload too, not just a /// pure-insert one. Promotion used to write straight into hot /// (_hot[key] = verdict) with no capacity check, so re-reading a working /// set bigger than one segment pulled the whole cold generation back into hot /// on top of what hot already held — hot alone reached 2 × SegmentCapacity and /// the total 3 ×, against a documented 2 ×. Pure-insert overflow never hits /// that path because every key is distinct. /// [Fact] public void PromotionOverflow_KeepsCacheBounded() { ScriptCompileVerdictCache.Clear(); // Fill past one rotation so a large working set is sitting in cold with // hot already partly full. const int WorkingSet = 3000; for (var i = 0; i < WorkingSet; i++) Lookup($"// promo {i}", static () => (true, null)); Assert.True(ScriptCompileVerdictCache.Evictions > 0); // Re-read the whole working set: every entry still in cold promotes. for (var i = 0; i < WorkingSet; i++) Lookup($"// promo {i}", static () => (true, null)); Assert.True(ScriptCompileVerdictCache.Count <= 4096, $"cache grew to {ScriptCompileVerdictCache.Count} entries under promotion, exceeding its two-generation bound"); } [Fact] public void SurfaceIsPartOfTheKey_AcrossEviction() { ScriptCompileVerdictCache.Clear(); const string code = "// same body, two surfaces"; Lookup(code, static () => (true, null)); var otherSurfaceCompiled = false; var verdict = ScriptCompileVerdictCache.GetOrAdd("OtherSurface", code, () => { otherSurfaceCompiled = true; return (false, "not valid against this surface"); }); // A verdict is never interchangeable across globals surfaces, and the // segmented cache must not weaken that. Assert.True(otherSurfaceCompiled); Assert.False(verdict.Ok); } } /// /// Serialises every test that touches the process-wide /// static. /// [CollectionDefinition("ScriptCompileVerdictCache")] public class ScriptCompileVerdictCacheCollection;