feat(engines): IVirtualTagEvaluator snapshot overload; Roslyn read cache stops fabricating Good (02/S14)

This commit is contained in:
Joseph Doherty
2026-08-20 10:45:11 -04:00
parent 60e5b40228
commit cc9b845f99
5 changed files with 166 additions and 19 deletions
@@ -7,6 +7,7 @@ using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Host.Engines;
@@ -539,6 +540,98 @@ public sealed class RoslynVirtualTagEvaluatorTests
$"{starved}/{totalEvaluations} evaluations missed the 5s budget");
}
// ── input quality reaches the script (archreview 02/S14) ────────────────────────────────
/// <summary>
/// A script reading <c>ctx.GetTag(path).StatusCode</c> sees the dependency's <b>real</b> status.
/// The read cache used to stamp every input <c>Good</c>, so a script could not detect a degraded
/// input at all — "broken input looks Good", the other door onto the same lie 02/S13 closed for
/// broken scripts.
/// </summary>
[Fact]
public void Evaluate_read_cache_carries_the_inputs_real_status_code()
{
const uint uncertain = 0x40000000u;
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
var stamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
var result = sut.Evaluate(
virtualTagId: "vt-quality",
expression: "return (long)ctx.GetTag(\"a\").StatusCode;",
dependencies: new Dictionary<string, DataValueSnapshot>
{
["a"] = new(7, uncertain, stamp, stamp),
});
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe((long)uncertain);
}
/// <summary>The dependency's own source timestamp reaches the script, not evaluation time.</summary>
[Fact]
public void Evaluate_read_cache_carries_the_inputs_real_source_timestamp()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
var stamp = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
var result = sut.Evaluate(
virtualTagId: "vt-stamp",
expression: "return ctx.GetTag(\"a\").SourceTimestampUtc;",
dependencies: new Dictionary<string, DataValueSnapshot>
{
["a"] = new(7, 0u, stamp, DateTime.UtcNow),
});
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(stamp);
}
/// <summary>
/// The value-only overload keeps working unchanged — it is what the interface's default
/// implementation and the five test fakes still use, which is the whole reason the new overload is
/// default-implemented rather than abstract.
/// </summary>
[Fact]
public void The_value_only_overload_still_evaluates()
{
using var sut = new RoslynVirtualTagEvaluator(NullLogger<RoslynVirtualTagEvaluator>.Instance, NoOpScriptRoot());
var result = sut.Evaluate(
virtualTagId: "vt-values",
expression: "return (int)ctx.GetTag(\"a\").Value + 1;",
dependencies: new Dictionary<string, object?> { ["a"] = 41 });
result.Success.ShouldBeTrue(result.Reason);
result.Value.ShouldBe(42);
}
/// <summary>
/// A fake implementing ONLY the value-only member still answers the snapshot overload, through the
/// interface's default projection. Without that default, adding the overload would have been a
/// breaking change for every existing <see cref="IVirtualTagEvaluator"/> implementation.
/// </summary>
[Fact]
public void The_snapshot_overload_falls_back_to_a_value_only_implementation()
{
IVirtualTagEvaluator fake = new ValueOnlyEvaluator();
var result = fake.Evaluate("vt-fake", "ignored", new Dictionary<string, DataValueSnapshot>
{
["a"] = new(41, 0u, DateTime.UtcNow, DateTime.UtcNow),
});
result.Success.ShouldBeTrue();
result.Value.ShouldBe(41);
}
/// <summary>An evaluator predating the snapshot overload: implements the value-only member only.</summary>
private sealed class ValueOnlyEvaluator : IVirtualTagEvaluator
{
public VirtualTagEvalResult Evaluate(
string virtualTagId, string expression, IReadOnlyDictionary<string, object?> dependencies)
=> VirtualTagEvalResult.Ok(dependencies["a"]);
}
/// <summary>Reads the count of the private compiled-script cache via reflection. The cache is a
/// <c>CompiledScriptCache&lt;,&gt;</c> which exposes a <c>Count</c> property (not <c>ICollection</c>).</summary>
private static int CompiledCacheCount(RoslynVirtualTagEvaluator sut)