Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
152 lines
5.6 KiB
C#
152 lines
5.6 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<FakeScriptContext, int>();
|
|
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<FakeScriptContext, int>();
|
|
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<FakeScriptContext, int>();
|
|
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<FakeScriptContext, int>();
|
|
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<FakeScriptContext, double>();
|
|
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<FakeScriptContext, int>();
|
|
|
|
// First attempt — undefined identifier, compile throws.
|
|
Should.Throw<Exception>(() => 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<FakeScriptContext, int>();
|
|
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<FakeScriptContext, int>();
|
|
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<FakeScriptContext, int>();
|
|
var boolCache = new CompiledScriptCache<FakeScriptContext, bool>();
|
|
|
|
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<FakeScriptContext, int>();
|
|
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
|
|
}
|
|
}
|