using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Scripting; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; /// /// Exercises the source-hash keyed compile cache. Roslyn compilation is the most /// expensive step in the evaluator pipeline; this cache collapses redundant /// compiles of unchanged scripts to zero-cost lookups + makes sure concurrent /// callers never double-compile. /// [Trait("Category", "Unit")] public sealed class CompiledScriptCacheTests { private sealed class CompileCountingGate { public int Count; } [Fact] public void First_call_compiles_and_caches() { var cache = new CompiledScriptCache(); cache.Count.ShouldBe(0); var e = cache.GetOrCompile("""return 42;"""); e.ShouldNotBeNull(); cache.Count.ShouldBe(1); cache.Contains("""return 42;""").ShouldBeTrue(); } [Fact] public void Identical_source_returns_the_same_compiled_evaluator() { var cache = new CompiledScriptCache(); var first = cache.GetOrCompile("""return 1;"""); var second = cache.GetOrCompile("""return 1;"""); ReferenceEquals(first, second).ShouldBeTrue(); cache.Count.ShouldBe(1); } [Fact] public void Different_source_produces_different_evaluator() { var cache = new CompiledScriptCache(); var a = cache.GetOrCompile("""return 1;"""); var b = cache.GetOrCompile("""return 2;"""); ReferenceEquals(a, b).ShouldBeFalse(); cache.Count.ShouldBe(2); } [Fact] public void Whitespace_difference_misses_cache() { // Documented behavior: reformatting a script recompiles. Simpler + cheaper // than the alternative (AST-canonicalize then hash) and doesn't happen often. var cache = new CompiledScriptCache(); cache.GetOrCompile("""return 1;"""); cache.GetOrCompile("return 1; "); // trailing whitespace — different hash cache.Count.ShouldBe(2); } [Fact] public async Task Cached_evaluator_still_runs_correctly() { var cache = new CompiledScriptCache(); var e = cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;"""); var ctx = new FakeScriptContext().Seed("In", 7.0); // Run twice through the cache — both must return the same correct value. var first = await e.RunAsync(ctx, TestContext.Current.CancellationToken); var second = await cache.GetOrCompile("""return (double)ctx.GetTag("In").Value * 3.0;""") .RunAsync(ctx, TestContext.Current.CancellationToken); first.ShouldBe(21.0); second.ShouldBe(21.0); } [Fact] public void Failed_compile_is_evicted_so_retry_with_corrected_source_works() { var cache = new CompiledScriptCache(); // First attempt — undefined identifier, compile throws. Should.Throw(() => cache.GetOrCompile("""return unknownIdentifier + 1;""")); cache.Count.ShouldBe(0, "failed compile must be evicted so retry can re-attempt"); // Retry with corrected source succeeds + caches. cache.GetOrCompile("""return 42;""").ShouldNotBeNull(); cache.Count.ShouldBe(1); } [Fact] public void Clear_drops_every_entry() { var cache = new CompiledScriptCache(); cache.GetOrCompile("""return 1;"""); cache.GetOrCompile("""return 2;"""); cache.Count.ShouldBe(2); cache.Clear(); cache.Count.ShouldBe(0); cache.Contains("""return 1;""").ShouldBeFalse(); } [Fact] public void Concurrent_compiles_of_the_same_source_deduplicate() { // LazyThreadSafetyMode.ExecutionAndPublication guarantees only one compile // even when multiple threads race GetOrCompile against an empty cache. // We can't directly count Roslyn compilations — but we can assert all // concurrent callers see the same evaluator instance. var cache = new CompiledScriptCache(); const string src = """return 99;"""; var tasks = Enumerable.Range(0, 20) .Select(_ => Task.Run(() => cache.GetOrCompile(src))) .ToArray(); Task.WhenAll(tasks).GetAwaiter().GetResult(); var firstInstance = tasks[0].Result; foreach (var t in tasks) ReferenceEquals(t.Result, firstInstance).ShouldBeTrue(); cache.Count.ShouldBe(1); } [Fact] public void Different_TContext_TResult_pairs_use_separate_cache_instances() { // Documented: each engine (virtual-tag / alarm-predicate / alarm-action) owns // its own cache. The type-parametric design makes this the default without // cross-contamination at the dictionary level. var intCache = new CompiledScriptCache(); var boolCache = new CompiledScriptCache(); intCache.GetOrCompile("""return 1;"""); boolCache.GetOrCompile("""return true;"""); intCache.Count.ShouldBe(1); boolCache.Count.ShouldBe(1); intCache.Contains("""return true;""").ShouldBeFalse(); boolCache.Contains("""return 1;""").ShouldBeFalse(); } [Fact] public void Null_source_throws_ArgumentNullException() { var cache = new CompiledScriptCache(); Should.Throw(() => cache.GetOrCompile(null!)); } [Fact] public void Failed_compile_eviction_does_not_remove_a_concurrent_retry_entry() { // Regression for Core.Scripting-006: when a faulted Lazy is observed by a thread, // the eviction must scope to that specific Lazy instance, not the key. If a // concurrent retry has already inserted a fresh Lazy under the same key between // the throw and the catch-block removal, the buggy TryRemove(key, out _) overload // evicts the retry entry. The fixed TryRemove(KeyValuePair<,>) overload compares // value identity, so only the faulted Lazy is removed. // // Deterministic setup: pre-populate the cache's internal dictionary with a // faulted Lazy whose factory itself swaps the entry to a fresh Lazy as a side // effect during the throw. By the time GetOrCompile reaches its catch block, the // dictionary holds the fresh entry under the same key — exactly the race window // the finding describes. The fix must leave the fresh entry in place. var cache = new CompiledScriptCache(); // Reach the private _cache + HashSource via reflection — they're private, so // InternalsVisibleTo doesn't help. var cacheField = typeof(CompiledScriptCache) .GetField("_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); cacheField.ShouldNotBeNull(); var dict = (System.Collections.Concurrent.ConcurrentDictionary< string, Lazy>>)cacheField!.GetValue(cache)!; const string source = """return 7;"""; var hashSourceMethod = typeof(CompiledScriptCache) .GetMethod("HashSource", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic); hashSourceMethod.ShouldNotBeNull(); var key = (string)hashSourceMethod!.Invoke(null, [source])!; // The fresh Lazy is what a concurrent retry would have inserted between the // faulted-throw and the catch's removal. Materialise it eagerly so we have a // stable reference to assert identity against. var fresh = new Lazy>( () => ScriptEvaluator.Compile(source), LazyThreadSafetyMode.ExecutionAndPublication); // The faulted Lazy throws — but only after swapping its own dictionary entry // for the fresh Lazy, modelling the race window between the throw and the // catch-block eviction. var faulted = new Lazy>( () => { dict[key] = fresh; throw new InvalidOperationException("bad compile"); }, LazyThreadSafetyMode.ExecutionAndPublication); dict[key] = faulted; // Drive GetOrCompile through the public API. It observes the faulted Lazy // currently under `key`, invokes .Value (which swaps in the fresh Lazy then // throws), and runs the catch block's eviction. The fix removes only the // specific faulted Lazy instance; the fresh entry survives. Should.Throw(() => cache.GetOrCompile(source)); dict.ContainsKey(key).ShouldBeTrue( "the fresh retry entry that won the race must survive the faulted Lazy eviction (Core.Scripting-006)"); ReferenceEquals(dict[key], fresh).ShouldBeTrue( "the entry under the key must still be the fresh Lazy — an unconditional TryRemove(key) would have evicted it"); } [Fact] public void Failed_compile_path_still_evicts_its_own_faulted_entry() { // Companion to the race test above: confirm the fix's value-scoped eviction // still removes the actual faulted Lazy (so retries with corrected source can // succeed — the original Core.Scripting test that locked the contract). var cache = new CompiledScriptCache(); Should.Throw(() => cache.GetOrCompile("""return unknownIdentifier + 1;""")); cache.Count.ShouldBe(0, "faulted Lazy must still be evicted after compile failure"); } }