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!)); } }