feat(host): F8b RoslynVirtualTagEvaluator — production virtual-tag eval

RoslynVirtualTagEvaluator wraps Core.Scripting.ScriptEvaluator + Core
.VirtualTags.VirtualTagContext into a single-tag IVirtualTagEvaluator
adapter. Caches the compiled ScriptEvaluator per unique expression so
the second-and-onwards Evaluate is an in-process method call against the
dependency dictionary. Compile/sandbox/runtime errors all surface as
VirtualTagEvalResult.Failure rather than propagating exceptions through
the VirtualTagActor message loop.

Single-tag scope: cross-tag ctx.SetVirtualTag writes are dropped + logged
because fan-out between actors is owned by DependencyMuxActor. Cycle
detection + cascade ordering stay in Core.VirtualTags.VirtualTagEngine
where they belong (loaded fleet-wide); this adapter keeps the actor
message handler simple.

Host adds Core.Scripting + Core.VirtualTags project refs, plus a
TargetWarningsAsErrors NU1608 suppression — Microsoft.CodeAnalysis.CSharp
.Scripting 4.12.0 pins Common to 4.12.0 but ASP.NET Core transitively
brings Microsoft.CodeAnalysis.Common 5.0.0; the surface we use is stable
across the drift (verified by Core.Scripting.Tests).

Program.cs binds RoslynVirtualTagEvaluator → IVirtualTagEvaluator on
driver-role hosts, replacing the F8-default NullVirtualTagEvaluator so
VirtualTagActor evaluates real user scripts at runtime.

6 new adapter tests cover: simple expression sums, cache reuse across
calls, compile-error denial, runtime-throw denial, empty-expression
denial, post-dispose denial. Host.IntegrationTests now 10/10 green.

Closes #79. F9b + #107 next.
This commit is contained in:
Joseph Doherty
2026-05-26 10:55:56 -04:00
parent 607dc51dec
commit 219d10a22d
4 changed files with 225 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
/// <summary>
/// F8b — verifies <see cref="RoslynVirtualTagEvaluator"/> compiles user expressions through
/// the Core.Scripting sandbox, runs them against the dependency dictionary, caches the
/// compiled assembly per source, and surfaces failures (compile error, sandbox violation,
/// runtime throw) as <c>VirtualTagEvalResult.Failure</c> instead of propagating exceptions.
/// </summary>
public sealed class RoslynVirtualTagEvaluatorTests
{
[Fact]
public void Evaluate_simple_addition_returns_summed_value()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
var result = sut.Evaluate(
virtualTagId: "vt-sum",
expression: "return (int)ctx.GetTag(\"a\").Value + (int)ctx.GetTag(\"b\").Value;",
dependencies: new Dictionary<string, object?> { ["a"] = 10, ["b"] = 32 });
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(42);
}
[Fact]
public void Evaluate_caches_compiled_expression_across_calls()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
const string expr = "return (int)ctx.GetTag(\"x\").Value * 2;";
var first = sut.Evaluate("vt-cache", expr, new Dictionary<string, object?> { ["x"] = 5 });
var second = sut.Evaluate("vt-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);
}
[Fact]
public void Evaluate_compile_error_returns_Failure_with_reason()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
var result = sut.Evaluate("vt-bad", "this is not valid C#;", new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason.ShouldNotBeNull();
result.Reason.ShouldContain("compile");
}
[Fact]
public void Evaluate_runtime_exception_returns_Failure_with_reason()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
var result = sut.Evaluate(
virtualTagId: "vt-div0",
expression: "int a = 0; return 1 / a;",
dependencies: new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason.ShouldNotBeNull();
result.Reason.ShouldContain("threw");
}
[Fact]
public void Evaluate_empty_expression_returns_Failure()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
sut.Evaluate("vt-empty", "", new Dictionary<string, object?>()).Success.ShouldBeFalse();
sut.Evaluate("vt-empty", " ", new Dictionary<string, object?>()).Success.ShouldBeFalse();
}
[Fact]
public void Evaluate_after_dispose_returns_Failure()
{
var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance);
sut.Dispose();
var result = sut.Evaluate("vt", "return 1;", new Dictionary<string, object?>());
result.Success.ShouldBeFalse();
result.Reason!.ShouldContain("disposed");
}
}