using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.Scripting;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;
///
/// Tests for the process-wide . The cache is
/// static/process-wide state, so this class shares the "SiteScriptCompileCache" xunit
/// collection with ScriptCompilationServiceTests and DeploymentWarmThenGateTests
/// (no cross-class parallelism between those three, so no concurrent
/// ).
///
///
///
/// Deferred-work register row 91. That collection does NOT stop the rest of the
/// assembly: any test class that compiles a script — Instance/Script actor tests, deployment
/// tests — goes through too, in parallel, and
/// bumps the same process-global /
/// . Absolute assertions such as
/// Assert.Equal(1, Hits) after a Clear() therefore only held while nothing else
/// happened to be compiling; they failed roughly 1 run in 4 of a full parallel solution run and
/// were always green in isolation.
///
///
/// The fix follows the register's row-74 pattern: assert the DELTA attributable to this test's
/// OWN keys across its own scoped action, never an absolute global value. Two habits carry it:
/// every test keys its cache entries on a per-test-instance unique token ( ),
/// so no other class can collide with or serve them; and the global counters are only ever
/// asserted as one-sided bounds (>= baseline + n ), since foreign traffic can only add.
/// A collection fixture spanning every script-compiling class in the assembly would fix it too,
/// but would serialise most of the suite for the benefit of five facts.
///
///
[Collection("SiteScriptCompileCache")]
public class SiteScriptCompileCacheTests
{
///
/// Slack allowed on bounds for entries inserted
/// by test classes outside this collection while a bound test runs (register row 91). The
/// exactness of the eviction claims comes from the per-key probes, not from this bound.
///
private const int ForeignEntrySlack = 512;
/// Per-test-instance token making every cache key in this test uniquely ours.
private readonly string _uniq = Guid.NewGuid().ToString("N");
/// Builds a cache key body that no other test class in the assembly can produce.
private string Code(string body) => $"/*{_uniq}*/ {body}";
private static ScriptCompilationResult Ok() =>
ScriptCompilationResult.Succeeded(CSharpScript.Create("1", ScriptOptions.Default, typeof(ScriptGlobals)));
[Fact]
public void GetOrAdd_SameCodeAndGlobals_ComputesOnce()
{
var code = Code("return 1;");
var hitsBefore = SiteScriptCompileCache.Hits;
var factoryCalls = 0;
ScriptCompilationResult Factory() { factoryCalls++; return Ok(); }
var r1 = SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals), Factory);
var r2 = SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals), Factory);
// Scoped to our own key, so these two are exact and are the real claim: one compile,
// and the second lookup returned that very instance rather than a re-computation.
Assert.Equal(1, factoryCalls);
Assert.Same(r1, r2);
// Hits is process-global; a parallel class's compile can only push the delta up, so the
// claim "the second lookup was counted as a hit" is a lower bound, not an equality.
Assert.True(SiteScriptCompileCache.Hits >= hitsBefore + 1,
$"expected the second GetOrAdd to register a hit; hits went {hitsBefore} -> {SiteScriptCompileCache.Hits}");
}
[Fact]
public void GetOrAdd_SameCode_DifferentGlobals_AreSeparateEntries()
{
var code = Code("1 > 0");
var scriptCompiles = 0;
var triggerCompiles = 0;
var script = SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals),
() => { scriptCompiles++; return Ok(); });
var trigger = SiteScriptCompileCache.GetOrAdd(code, typeof(TriggerExpressionGlobals),
() => { triggerCompiles++; return Ok(); });
// "No cross-globals hit" asserted where it is actually observable and ours: the second
// lookup had to compute, and the two globals surfaces hold distinct entries. (The old
// form — Count == 2 and Hits == 0 on the process-global counters — was only true while
// no other class was compiling.)
Assert.Equal(1, scriptCompiles);
Assert.Equal(1, triggerCompiles);
Assert.NotSame(script, trigger);
// Each entry is independently addressable afterwards: re-getting either must serve the
// matching cached result, never the other globals surface's.
Assert.Same(script, SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals),
() => throw new InvalidOperationException("script entry was not cached")));
Assert.Same(trigger, SiteScriptCompileCache.GetOrAdd(code, typeof(TriggerExpressionGlobals),
() => throw new InvalidOperationException("trigger entry was not cached")));
}
[Fact]
public void Overflow_StaysWithinTheBound()
{
SiteScriptCompileCache.Clear();
for (var i = 0; i <= SiteScriptCompileCache.MaxEntries; i++)
SiteScriptCompileCache.GetOrAdd(Code($"return {i};"), typeof(ScriptGlobals), Ok);
// MaxEntries + 1 distinct inserts must not all be resident — the sweep ran. The bound
// carries slack for foreign inserts (register row 91); the sharp claim is the eviction
// probe below, which is scoped to keys only this test can produce.
Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries + ForeignEntrySlack,
$"cache grew past its bound: {SiteScriptCompileCache.Count} vs max {SiteScriptCompileCache.MaxEntries}");
var recomputed = 0;
for (var i = 0; i < 16; i++)
{
var code = Code($"return {i};");
SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals), () => { recomputed++; return Ok(); });
}
Assert.True(recomputed > 0, "no oldest-batch entry was evicted — the overflow sweep did not run");
}
// ── WP3.1: approximate LRU replaced the wholesale-Clear overflow cliff ──────────
///
/// WP3.1 (test group 8): overflow must evict only the OLDEST batch, keeping hot entries.
/// The old behaviour cleared all 1024 entries, so the very next deploy or Instance-Actor
/// start paid a full recompile storm on actor threads.
///
[Fact]
public void Overflow_EvictsOldestBatch_AndKeepsRecentlyTouchedEntries()
{
// Note: the cache is process-wide static and other test classes compile into it
// concurrently, so the assertions below are phrased as properties of the eviction
// policy (what survives, what does not, nothing is wiped) rather than as exact
// counts, which no test can own here.
SiteScriptCompileCache.Clear();
// Fill to the bound. Entries 1..N are inserted oldest-first.
for (var i = 0; i < SiteScriptCompileCache.MaxEntries; i++)
SiteScriptCompileCache.GetOrAdd(Code($"return {i};"), typeof(ScriptGlobals), Ok);
// Touch entry 0 so it carries the NEWEST access stamp despite being inserted first.
var hot = SiteScriptCompileCache.GetOrAdd(Code("return 0;"), typeof(ScriptGlobals), Ok);
var hitsAfterTouch = SiteScriptCompileCache.Hits;
// Push well past the bound so at least one eviction sweep definitely runs.
for (var i = 0; i < 200; i++)
SiteScriptCompileCache.GetOrAdd(Code($"return overflow{i};"), typeof(ScriptGlobals), Ok);
// Nothing was cleared wholesale: the cache is still most of the way full and the hit
// counter survived (Clear() would have reset it to 0).
Assert.True(SiteScriptCompileCache.Count > SiteScriptCompileCache.MaxEntries / 2,
$"cache collapsed to {SiteScriptCompileCache.Count} entries — this looks like a wholesale clear");
Assert.True(SiteScriptCompileCache.Hits >= hitsAfterTouch);
// The touched entry survived the sweep — a hit, not a recompile. This is the LRU
// property: recency, not insertion order, decides what stays.
var hitsBefore = SiteScriptCompileCache.Hits;
var again = SiteScriptCompileCache.GetOrAdd(Code("return 0;"), typeof(ScriptGlobals),
() => throw new InvalidOperationException("hot entry was evicted — LRU is not keeping recently-used entries"));
// Assert.Same is the sharp claim (our own key, our own instance). The counter check is a
// lower bound: Hits is process-global and a parallel class's hit lands in the same window
// (register row 91 — this was `Assert.Equal(hitsBefore + 1, …)`).
Assert.Same(hot, again);
Assert.True(SiteScriptCompileCache.Hits >= hitsBefore + 1,
$"expected the surviving hot entry to register a hit; hits went {hitsBefore} -> {SiteScriptCompileCache.Hits}");
// …and untouched old entries genuinely were evicted, so the sweep really ran.
var recomputed = 0;
for (var i = 1; i <= 16; i++)
{
var code = Code($"return {i};");
SiteScriptCompileCache.GetOrAdd(code, typeof(ScriptGlobals),
() => { recomputed++; return Ok(); });
}
Assert.True(recomputed > 0, "no old entry was evicted — the overflow sweep did not run");
}
///
/// WP3.1: concurrent inserts crossing the bound together must not blow past it — the
/// eviction sweep is double-checked under its own lock precisely so a stampede performs
/// one sweep rather than one per racing thread.
///
[Fact]
public async Task ConcurrentGetOrAddStorm_KeepsTheBound()
{
SiteScriptCompileCache.Clear();
const int perTask = 400;
var tasks = Enumerable.Range(0, 8).Select(t => Task.Run(() =>
{
for (var i = 0; i < perTask; i++)
SiteScriptCompileCache.GetOrAdd(Code($"return {t}_{i};"), typeof(ScriptGlobals), Ok);
}));
await Task.WhenAll(tasks);
// A small transient overshoot is by design: threads that lose the double-checked
// eviction race still insert their own entry afterwards, so the ceiling is "bounded",
// not "never exceeded by one". The property under test is that 3200 distinct inserts
// do not accumulate — before WP3.1 this was a wholesale Clear(), and a per-entry
// eviction bug would show up here as unbounded growth, not as a handful of extra rows.
// (Other test classes compile into this process-wide cache concurrently, which is the
// other reason the bound is asserted with slack rather than exactly.)
Assert.True(SiteScriptCompileCache.Count <= SiteScriptCompileCache.MaxEntries + ForeignEntrySlack,
$"cache grew past its bound: {SiteScriptCompileCache.Count} vs max {SiteScriptCompileCache.MaxEntries} " +
$"after {8 * perTask} distinct inserts");
}
}