merge wt/t0-4 into v3/batch2-raw-ui (Track 0)
This commit is contained in:
@@ -21,6 +21,7 @@
|
||||
<Project Path="src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/src/Drivers/">
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj" />
|
||||
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Contracts.csproj" />
|
||||
@@ -81,6 +82,7 @@
|
||||
<Project Path="tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/ZB.MOM.WW.OtOpcUa.Security.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/Drivers/">
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.Tests.csproj" />
|
||||
<Project Path="tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.csproj" />
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
using SerilogLogger = Serilog.ILogger;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// T0-4 — the Calculation driver's script evaluator. A calc tag is a raw tag whose value is
|
||||
/// produced by a C# script over other tags' live values. This evaluator is a deliberate mirror of
|
||||
/// the VirtualTag <c>RoslynVirtualTagEvaluator</c>: it compiles each unique source once via
|
||||
/// <see cref="ScriptEvaluator{TContext, TResult}"/> (the Roslyn-backed sandbox), caches the
|
||||
/// resulting evaluator keyed by source in a <see cref="CompiledScriptCache{TContext, TResult}"/>,
|
||||
/// and runs steady-state evaluations as in-process method invocations against the dependency
|
||||
/// dictionary — fast enough to run inline on the driver's dispatch. It reuses
|
||||
/// <see cref="VirtualTagContext"/> verbatim so the Monaco editor, sandbox, and diagnostics pipeline
|
||||
/// apply to calc scripts with no divergence.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Single-tag mode.</b> A calc tag produces exactly one output value — its own — so cross-tag
|
||||
/// <c>ctx.SetVirtualTag</c> writes are dropped (and logged at Debug). Fan-out between tags is owned
|
||||
/// by the host's <c>DependencyMuxActor</c> (calc-of-calc chains re-enter the mux), never by the
|
||||
/// eval engine, exactly as in the VirtualTag adapter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Error surfacing (for WP7).</b> This evaluator never throws for script faults: compile error,
|
||||
/// sandbox violation, runtime throw, and timeout all return <see cref="VirtualTagEvalResult.Failure"/>
|
||||
/// with a descriptive <c>Reason</c>. A successful run returns <see cref="VirtualTagEvalResult.Ok"/>
|
||||
/// carrying the computed value (which may be null). WP7's <c>CalculationDriver</c> maps a
|
||||
/// <c>Failure</c> to Bad quality + a <c>ScriptLogEntry</c> and an <c>Ok</c> to the published value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cache management.</b> Implements <see cref="IScriptCacheOwner"/> so WP7 can drop compiled
|
||||
/// scripts at each deploy generation whose source set changed — mirroring the VirtualTag adapter's
|
||||
/// apply-boundary clear, which is what stops collectible <c>AssemblyLoadContext</c>s accreting across
|
||||
/// script edits.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
{
|
||||
// Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose()
|
||||
// that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the
|
||||
// WP7 driver per deploy generation — is what stops ALCs accreting across script edits.
|
||||
private readonly CompiledScriptCache<VirtualTagContext, object?> _cache = new();
|
||||
private readonly ILogger<CalculationEvaluator> _logger;
|
||||
private readonly SerilogLogger _scriptRoot;
|
||||
private readonly TimeSpan _runTimeout;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes a new <see cref="CalculationEvaluator"/>.</summary>
|
||||
/// <param name="logger">Logger for host-side compilation/execution diagnostics.</param>
|
||||
/// <param name="scriptRoot">Root script logger; user <c>ctx.Logger.*</c> output flows through this to the Script-log page.</param>
|
||||
/// <param name="runTimeout">Wall-clock budget per script run; defaults to 2 seconds (parity with the VT evaluator).</param>
|
||||
public CalculationEvaluator(
|
||||
ILogger<CalculationEvaluator> logger,
|
||||
ScriptRootLogger scriptRoot,
|
||||
TimeSpan? runTimeout = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_scriptRoot = (scriptRoot ?? throw new ArgumentNullException(nameof(scriptRoot))).Logger;
|
||||
_runTimeout = runTimeout ?? TimeSpan.FromSeconds(2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluate <paramref name="scriptSource"/> for the calc tag <paramref name="calcTagId"/> against
|
||||
/// the snapshot in <paramref name="dependencies"/> (RawPath → last value). Never throws — script
|
||||
/// faults are reported via <see cref="VirtualTagEvalResult.Failure"/>; a successful run returns
|
||||
/// <see cref="VirtualTagEvalResult.Ok"/> carrying the single computed value.
|
||||
/// </summary>
|
||||
/// <param name="calcTagId">Identity of the calc tag (bound to the script log; in the live path equals the script id).</param>
|
||||
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
|
||||
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
|
||||
public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary<string, object?> dependencies)
|
||||
{
|
||||
if (_disposed) return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||
if (string.IsNullOrWhiteSpace(scriptSource)) return VirtualTagEvalResult.Failure("empty expression");
|
||||
|
||||
// Passthrough fast-path: the mirror shape `return ctx.GetTag("X").Value;` needs no Roslyn.
|
||||
// Semantics are byte-identical to the compiled mirror: a present dependency returns its value
|
||||
// (Ok), an absent one makes GetTag yield a Bad snapshot whose .Value is null, so the script
|
||||
// returns null — Ok(null) here too. Near-misses fall through to the compiled path.
|
||||
if (PassthroughScript.TryMatch(scriptSource, out var passthroughRef))
|
||||
{
|
||||
return dependencies.TryGetValue(passthroughRef, out var ptValue)
|
||||
? VirtualTagEvalResult.Ok(ptValue)
|
||||
: VirtualTagEvalResult.Ok(null);
|
||||
}
|
||||
|
||||
var readCache = BuildReadCache(dependencies);
|
||||
// Per-evaluation script logger: bind both ScriptId and CalcTagId (via the VirtualTagId slot, which
|
||||
// the Script-log page attributes on) from the calc-tag id so each line is attributable.
|
||||
var scriptLog = _scriptRoot
|
||||
.ForContext(ScriptLoggerFactory.ScriptIdProperty, calcTagId)
|
||||
.ForContext(ScriptLoggerFactory.VirtualTagIdProperty, calcTagId);
|
||||
var context = new VirtualTagContext(
|
||||
readCache,
|
||||
// Single-tag mode: a calc tag has exactly one output (its own), so cross-tag writes are dropped.
|
||||
setVirtualTag: (path, _) =>
|
||||
_logger.LogDebug("Calculation {Id}: cross-tag write to {Path} dropped (single-tag evaluator)",
|
||||
calcTagId, path),
|
||||
logger: scriptLog);
|
||||
|
||||
// Fetch + run inside a bounded loop so an ObjectDisposedException caused by a concurrent
|
||||
// ClearCompiledScripts (which disposed the evaluator between GetOrCompile and the run's
|
||||
// disposed guard) re-fetches and retries exactly once. The loop runs at most twice.
|
||||
for (var attempt = 0; ; attempt++)
|
||||
{
|
||||
ScriptEvaluator<VirtualTagContext, object?> evaluator;
|
||||
try
|
||||
{
|
||||
evaluator = _cache.GetOrCompile(scriptSource);
|
||||
}
|
||||
catch (CompilationErrorException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: Roslyn compile failed", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"compile error: {ex.Message}");
|
||||
}
|
||||
catch (ScriptSandboxViolationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: sandbox violation", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"sandbox violation: {ex.Message}");
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Fetch-time disposal is a shutdown signal (the cache is tearing down), not the
|
||||
// apply-boundary race — never retry.
|
||||
return VirtualTagEvalResult.Failure("evaluator disposed");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: compile threw", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"compile failure: {ex.Message}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Route through TimedScriptEvaluator (Task.Run + WaitAsync): a raw CancellationToken can't
|
||||
// interrupt a CPU-bound/infinite-loop Roslyn script, so the timed wrapper is what bounds a
|
||||
// runaway script to the wall-clock budget instead of wedging the driver's dispatch.
|
||||
var timed = new TimedScriptEvaluator<VirtualTagContext, object?>(evaluator, _runTimeout);
|
||||
var raw = timed.RunAsync(context).GetAwaiter().GetResult();
|
||||
return VirtualTagEvalResult.Ok(raw);
|
||||
}
|
||||
catch (ScriptTimeoutException)
|
||||
{
|
||||
return VirtualTagEvalResult.Failure($"script timed out after {_runTimeout.TotalSeconds:F1}s");
|
||||
}
|
||||
catch (ObjectDisposedException) when (!_disposed && attempt == 0)
|
||||
{
|
||||
// A concurrent ClearCompiledScripts disposed the evaluator between our GetOrCompile and the
|
||||
// run's disposed-guard. Re-fetch (recompiling the same source into the CURRENT generation)
|
||||
// and retry once. A second disposal mid-retry falls through to the failure path below.
|
||||
continue;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return VirtualTagEvalResult.Failure("evaluator disposed during evaluation");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Calculation {Id}: script execution threw", calcTagId);
|
||||
return VirtualTagEvalResult.Failure($"script threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearCompiledScripts() => _cache.Clear();
|
||||
|
||||
private static IReadOnlyDictionary<string, DataValueSnapshot> BuildReadCache(
|
||||
IReadOnlyDictionary<string, object?> deps)
|
||||
{
|
||||
// VirtualTagContext.GetTag returns a DataValueSnapshot — wrap each raw dep value as Good-quality
|
||||
// so the script's `(int)ctx.GetTag("a").Value` pattern works. Null values stay null.
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var cache = new Dictionary<string, DataValueSnapshot>(StringComparer.Ordinal);
|
||||
foreach (var kv in deps)
|
||||
{
|
||||
cache[kv.Key] = new DataValueSnapshot(kv.Value, StatusCode: 0u, SourceTimestampUtc: nowUtc, ServerTimestampUtc: nowUtc);
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>Disposes the evaluator, draining + disposing every cached compile's collectible ALC.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cache.Dispose();
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation</RootNamespace>
|
||||
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Calculation</AssemblyName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Scripting infra shared with the VirtualTag Roslyn evaluator. Core.Scripting brings
|
||||
CompiledScriptCache / TimedScriptEvaluator / ScriptEvaluator / the sandbox exceptions /
|
||||
ScriptRootLogger + ScriptLoggerFactory (and, transitively, Microsoft.CodeAnalysis
|
||||
for CompilationErrorException). Core.Scripting.Abstractions owns VirtualTagContext +
|
||||
PassthroughScript; Commons owns VirtualTagEvalResult + IScriptCacheOwner; Core.Abstractions
|
||||
owns DataValueSnapshot. -->
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj"/>
|
||||
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// T0-4 — parity tests for <see cref="CalculationEvaluator"/> against the VirtualTag
|
||||
/// <c>RoslynVirtualTagEvaluator</c> it mirrors: the passthrough fast-path, the wall-clock timeout,
|
||||
/// sandbox-violation rejection, single-tag <c>SetVirtualTag</c> drop, compile-error surfacing, and
|
||||
/// the compile-cache reuse / clear contract. All script faults surface as
|
||||
/// <c>VirtualTagEvalResult.Failure</c> (never a thrown exception) so WP7 can map them to Bad quality.
|
||||
/// </summary>
|
||||
public sealed class CalculationEvaluatorTests
|
||||
{
|
||||
/// <summary>Builds a no-op <see cref="ScriptRootLogger"/> for tests that don't assert on script logging.</summary>
|
||||
private static ScriptRootLogger NoOpScriptRoot() =>
|
||||
new(new LoggerConfiguration().CreateLogger());
|
||||
|
||||
// ── passthrough fast-path ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>The mirror shape <c>return ctx.GetTag("X").Value;</c> returns the dependency value
|
||||
/// verbatim without invoking Roslyn (parity with the VT evaluator's fast-path).</summary>
|
||||
[Fact]
|
||||
public void Passthrough_returns_dependency_value_without_compiling()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
calcTagId: "calc-mirror",
|
||||
scriptSource: "return ctx.GetTag(\"a\").Value;",
|
||||
dependencies: new Dictionary<string, object?> { ["a"] = 42 });
|
||||
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>Decisive proof the fast-path skips Roslyn: the compiled-script cache stays EMPTY after a
|
||||
/// mirror evaluation, then grows to one entry once a genuine (non-mirror) expression forces compilation.</summary>
|
||||
[Fact]
|
||||
public void Passthrough_does_not_populate_the_compiled_script_cache()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
sut.Evaluate("calc-mirror", "return ctx.GetTag(\"a\").Value;",
|
||||
new Dictionary<string, object?> { ["a"] = 1 });
|
||||
CompiledCacheCount(sut).ShouldBe(0);
|
||||
|
||||
sut.Evaluate("calc-real", "return (int)ctx.GetTag(\"a\").Value + 1;",
|
||||
new Dictionary<string, object?> { ["a"] = 1 });
|
||||
CompiledCacheCount(sut).ShouldBe(1);
|
||||
}
|
||||
|
||||
// ── timeout ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>An infinite-loop script returns Failure ("timed out") within the wall-clock budget instead
|
||||
/// of wedging the caller. The whole call is WaitAsync-bounded so a regression FAILS rather than hangs.</summary>
|
||||
[Fact]
|
||||
public async Task Evaluate_infinite_loop_script_fails_within_timeout_and_does_not_hang()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(
|
||||
NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot(), TimeSpan.FromMilliseconds(200));
|
||||
|
||||
var evalTask = Task.Run(
|
||||
() => sut.Evaluate("calc-loop", "while(true){}return 0;", new Dictionary<string, object?>()),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var result = await evalTask.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason.ShouldNotBeNull();
|
||||
result.Reason.ShouldContain("timed out");
|
||||
}
|
||||
|
||||
// ── sandbox violation ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>A script touching a forbidden API (file I/O) is rejected the way the VT evaluator rejects
|
||||
/// it: the <c>ScriptSandboxViolationException</c> from compile is caught and returned as a Failure whose
|
||||
/// reason names the sandbox violation — never propagated as an exception.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_sandbox_violation_returns_Failure_and_does_not_throw()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
calcTagId: "calc-fileio",
|
||||
scriptSource: "return System.IO.File.ReadAllText(\"c:/secrets.txt\");",
|
||||
dependencies: new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason.ShouldNotBeNull();
|
||||
result.Reason.ShouldContain("sandbox violation");
|
||||
}
|
||||
|
||||
// ── single-tag mode: ctx.SetVirtualTag drop ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Single-tag mode: a script calling <c>ctx.SetVirtualTag</c> has that cross-tag write dropped
|
||||
/// (logged at Debug) while the script's own return value — the calc tag's single output — is preserved.</summary>
|
||||
[Fact]
|
||||
public void SetVirtualTag_call_is_dropped_and_logged_single_output_preserved()
|
||||
{
|
||||
var logger = new CapturingLogger();
|
||||
using var sut = new CalculationEvaluator(logger, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
calcTagId: "calc-fanout",
|
||||
scriptSource: "ctx.SetVirtualTag(\"other\", 99); return (int)ctx.GetTag(\"a\").Value + 1;",
|
||||
dependencies: new Dictionary<string, object?> { ["a"] = 41 });
|
||||
|
||||
// The write to "other" is dropped, but the calc tag's own output flows through unaffected.
|
||||
result.Success.ShouldBeTrue(result.Reason);
|
||||
result.Value.ShouldBe(42);
|
||||
|
||||
// The drop was logged (single-tag evaluator), never applied.
|
||||
logger.Entries.ShouldContain(e =>
|
||||
e.Level == LogLevel.Debug && e.Message.Contains("dropped") && e.Message.Contains("other"));
|
||||
}
|
||||
|
||||
// ── compile error ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>A compile error returns Failure with a descriptive reason (parity with the VT evaluator).</summary>
|
||||
[Fact]
|
||||
public void Evaluate_compile_error_returns_Failure_with_reason()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate("calc-bad", "this is not valid C#;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason.ShouldNotBeNull();
|
||||
result.Reason.ShouldContain("compile");
|
||||
}
|
||||
|
||||
/// <summary>A runtime throw returns Failure ("threw") rather than propagating (parity with the VT evaluator).</summary>
|
||||
[Fact]
|
||||
public void Evaluate_runtime_exception_returns_Failure_with_reason()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
var result = sut.Evaluate(
|
||||
calcTagId: "calc-div0",
|
||||
scriptSource: "int a = 0; return 1 / a;",
|
||||
dependencies: new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason.ShouldNotBeNull();
|
||||
result.Reason.ShouldContain("threw");
|
||||
}
|
||||
|
||||
// ── cache reuse + clear ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>A compiled (non-mirror) expression is cached and re-used across calls: two evaluations of the
|
||||
/// same source produce fresh values but leave the cache at exactly one entry.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_caches_compiled_expression_across_calls()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
||||
const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
|
||||
|
||||
var first = sut.Evaluate("calc-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
|
||||
var second = sut.Evaluate("calc-cache", expr, new Dictionary<string, object?> { ["x"] = 7 });
|
||||
|
||||
first.Success.ShouldBeTrue(first.Reason);
|
||||
first.Value.ShouldBe(10);
|
||||
second.Success.ShouldBeTrue(second.Reason);
|
||||
second.Value.ShouldBe(14);
|
||||
CompiledCacheCount(sut).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary><see cref="CalculationEvaluator.ClearCompiledScripts"/> drops every cached compile — the hook
|
||||
/// WP7 calls per deploy generation to release stale AssemblyLoadContexts — and the same source recompiles
|
||||
/// cleanly afterward.</summary>
|
||||
[Fact]
|
||||
public void ClearCompiledScripts_empties_cache_then_recompiles()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
||||
const string expr = "return (int)ctx.GetTag(\"a\").Value + 1;";
|
||||
|
||||
sut.Evaluate("calc-clear", expr, new Dictionary<string, object?> { ["a"] = 1 });
|
||||
CompiledCacheCount(sut).ShouldBe(1);
|
||||
|
||||
sut.ClearCompiledScripts();
|
||||
CompiledCacheCount(sut).ShouldBe(0);
|
||||
|
||||
var again = sut.Evaluate("calc-clear", expr, new Dictionary<string, object?> { ["a"] = 41 });
|
||||
again.Success.ShouldBeTrue(again.Reason);
|
||||
again.Value.ShouldBe(42);
|
||||
CompiledCacheCount(sut).ShouldBe(1);
|
||||
}
|
||||
|
||||
// ── guard rails ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Empty / whitespace scripts return Failure (parity with the VT evaluator).</summary>
|
||||
[Fact]
|
||||
public void Evaluate_empty_expression_returns_Failure()
|
||||
{
|
||||
using var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
||||
|
||||
sut.Evaluate("calc-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||
sut.Evaluate("calc-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Evaluation after disposal returns Failure ("disposed") rather than throwing.</summary>
|
||||
[Fact]
|
||||
public void Evaluate_after_dispose_returns_Failure()
|
||||
{
|
||||
var sut = new CalculationEvaluator(NullLogger<CalculationEvaluator>.Instance, NoOpScriptRoot());
|
||||
sut.Dispose();
|
||||
|
||||
var result = sut.Evaluate("calc", "return 1;", new Dictionary<string, object?>());
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Reason!.ShouldContain("disposed");
|
||||
}
|
||||
|
||||
/// <summary>Reads the count of the private <c>CompiledScriptCache</c> (which exposes a <c>Count</c>
|
||||
/// property) via reflection — mirrors the VT evaluator test's cache-count probe.</summary>
|
||||
private static int CompiledCacheCount(CalculationEvaluator sut)
|
||||
{
|
||||
var field = typeof(CalculationEvaluator)
|
||||
.GetField("_cache", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
field.ShouldNotBeNull();
|
||||
var cache = field.GetValue(sut)!;
|
||||
var countProp = cache.GetType().GetProperty("Count");
|
||||
countProp.ShouldNotBeNull();
|
||||
return (int)countProp.GetValue(cache)!;
|
||||
}
|
||||
|
||||
/// <summary>Minimal <see cref="ILogger{T}"/> that records emitted entries so the SetVirtualTag-drop
|
||||
/// test can assert the Debug "dropped" line was logged.</summary>
|
||||
private sealed class CapturingLogger : ILogger<CalculationEvaluator>
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3"/>
|
||||
<PackageReference Include="Shouldly"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation\ZB.MOM.WW.OtOpcUa.Driver.Calculation.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user