refactor(virtualtags): retire dormant VirtualTagEngine/Source/Definition + TimerTriggerScheduler (#603)
TimerTriggerScheduler was welded to the engine and has no production consumer. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory <see cref="ITagUpstreamSource"/> for tests. Seed tag values via
|
||||
/// <see cref="Set"/>, push changes via <see cref="Push"/>. Tracks subscriptions so
|
||||
/// tests can assert the engine disposes them on reload / shutdown.
|
||||
/// </summary>
|
||||
public sealed class FakeUpstream : ITagUpstreamSource
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Gets the number of active subscriptions.</summary>
|
||||
public int ActiveSubscriptionCount { get; private set; }
|
||||
|
||||
/// <summary>Sets a tag value without notifying subscribers.</summary>
|
||||
/// <param name="path">The path to the tag.</param>
|
||||
/// <param name="value">The value to set.</param>
|
||||
/// <param name="statusCode">The OPC UA status code for the value.</param>
|
||||
public void Set(string path, object value, uint statusCode = 0u)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
|
||||
}
|
||||
|
||||
/// <summary>Sets a tag value and notifies all subscribers.</summary>
|
||||
/// <param name="path">The path to the tag.</param>
|
||||
/// <param name="value">The value to set.</param>
|
||||
/// <param name="statusCode">The OPC UA status code for the value.</param>
|
||||
public void Push(string path, object value, uint statusCode = 0u)
|
||||
{
|
||||
Set(path, value, statusCode);
|
||||
if (_subs.TryGetValue(path, out var list))
|
||||
{
|
||||
Action<string, DataValueSnapshot>[] snap;
|
||||
lock (list) { snap = list.ToArray(); }
|
||||
foreach (var obs in snap) obs(path, _values[path]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the current value of a tag.</summary>
|
||||
/// <param name="path">The path to the tag.</param>
|
||||
public DataValueSnapshot ReadTag(string path)
|
||||
=> _values.TryGetValue(path, out var v)
|
||||
? v
|
||||
: new DataValueSnapshot(null, 0x80340000u, null, DateTime.UtcNow);
|
||||
|
||||
/// <summary>Subscribes to tag value changes.</summary>
|
||||
/// <param name="path">The path to the tag.</param>
|
||||
/// <param name="observer">The callback to invoke when the tag value changes.</param>
|
||||
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
var list = _subs.GetOrAdd(path, _ => []);
|
||||
lock (list) { list.Add(observer); }
|
||||
ActiveSubscriptionCount++;
|
||||
return new Unsub(this, path, observer);
|
||||
}
|
||||
|
||||
private sealed class Unsub : IDisposable
|
||||
{
|
||||
private readonly FakeUpstream _up;
|
||||
private readonly string _path;
|
||||
private readonly Action<string, DataValueSnapshot> _observer;
|
||||
|
||||
/// <summary>Initializes a new subscription unsubscription handler.</summary>
|
||||
/// <param name="up">The parent FakeUpstream instance.</param>
|
||||
/// <param name="path">The tag path.</param>
|
||||
/// <param name="observer">The observer callback to unsubscribe.</param>
|
||||
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
|
||||
{
|
||||
_up = up; _path = path; _observer = observer;
|
||||
}
|
||||
|
||||
/// <summary>Unsubscribes from tag value changes.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_up._subs.TryGetValue(_path, out var list))
|
||||
{
|
||||
lock (list)
|
||||
{
|
||||
if (list.Remove(_observer))
|
||||
_up.ActiveSubscriptionCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TimerTriggerSchedulerTests
|
||||
{
|
||||
/// <summary>Verifies that timer interval causes periodic reevaluation of virtual tags.</summary>
|
||||
[Fact]
|
||||
public async Task Timer_interval_causes_periodic_reevaluation()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
// Counter source — re-eval should pick up new value each tick.
|
||||
var counter = 0;
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger),
|
||||
logger);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Counter", DriverDataType.Int32,
|
||||
"""return ctx.Now.Millisecond;""", // changes on every evaluation
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(100))]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"Counter", DriverDataType.Int32,
|
||||
"""return ctx.Now.Millisecond;""",
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(100))]);
|
||||
|
||||
// Watch the value change across ticks.
|
||||
var snapshots = new List<object?>();
|
||||
using var sub = engine.Subscribe("Counter", (_, v) => snapshots.Add(v.Value));
|
||||
|
||||
await Task.Delay(500);
|
||||
|
||||
snapshots.Count.ShouldBeGreaterThanOrEqualTo(3, "At least 3 ticks in 500ms at 100ms cadence");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that tags without TimerInterval are not scheduled.</summary>
|
||||
[Fact]
|
||||
public async Task Tags_without_TimerInterval_not_scheduled()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"NoTimer", DriverDataType.Int32, """return 1;""")]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"NoTimer", DriverDataType.Int32, """return 1;""")]);
|
||||
|
||||
var events = new List<int>();
|
||||
using var sub = engine.Subscribe("NoTimer", (_, v) => events.Add((int)(v.Value ?? 0)));
|
||||
|
||||
await Task.Delay(300);
|
||||
events.Count.ShouldBe(0, "No TimerInterval = no timer ticks");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Start groups tags by interval into shared timers.</summary>
|
||||
[Fact]
|
||||
public void Start_groups_tags_by_interval_into_shared_timers()
|
||||
{
|
||||
// Smoke test — Start on a definition list with two distinct intervals must not
|
||||
// throw. Group count matches unique intervals.
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(5)),
|
||||
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
Should.NotThrow(() => sched.Start(new[]
|
||||
{
|
||||
new VirtualTagDefinition("Fast", DriverDataType.Int32, """return 1;""", TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
new VirtualTagDefinition("Slow", DriverDataType.Int32, """return 2;""", TimerInterval: TimeSpan.FromSeconds(5)),
|
||||
new VirtualTagDefinition("AlsoFast", DriverDataType.Int32, """return 3;""", TimerInterval: TimeSpan.FromSeconds(1)),
|
||||
}));
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-007: timer ticks must not block pool threads and must skip when prior tick is still running -----
|
||||
|
||||
/// <summary>Verifies that tick is skipped when the prior tick for the same group is still running.</summary>
|
||||
[Fact]
|
||||
public async Task Tick_skips_when_prior_tick_for_the_same_group_is_still_running()
|
||||
{
|
||||
// Regression for Core.VirtualTags-007: if a single tick takes longer than the
|
||||
// interval, subsequent timer callbacks must NOT each pin a thread-pool thread
|
||||
// waiting on the same evaluation gate. The scheduler tracks an in-flight flag
|
||||
// per group and skips a new tick when the prior one is still running.
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
|
||||
// Slow script — each evaluation takes longer than several timer intervals.
|
||||
const int slowMs = 250;
|
||||
const int intervalMs = 50;
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Slow", DriverDataType.Int32,
|
||||
$$"""
|
||||
var end = DateTime.UtcNow.AddMilliseconds({{slowMs}});
|
||||
while (DateTime.UtcNow < end) { }
|
||||
return (int)ctx.GetTag("In").Value;
|
||||
""",
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(intervalMs))]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"Slow", DriverDataType.Int32,
|
||||
"",
|
||||
ChangeTriggered: false,
|
||||
TimerInterval: TimeSpan.FromMilliseconds(intervalMs))]);
|
||||
|
||||
// Wait long enough for many timer ticks at 50ms while one evaluation
|
||||
// (~250ms each) holds the engine. Window is 600ms ~ 12 ticks.
|
||||
await Task.Delay(600);
|
||||
|
||||
// With the fix in place, ticks that fire while the previous one for the same
|
||||
// group is still running are skipped. The skipped count must be measurable; if
|
||||
// SkippedTickCount is still 0 after 600ms with ~12 ticks fired and a 250ms eval,
|
||||
// the fix is not working — at minimum 3-4 ticks must have been skipped.
|
||||
sched.SkippedTickCount.ShouldBeGreaterThan(2,
|
||||
"ticks that fire while the prior tick for the same group is still running must be skipped");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a disposed scheduler stops firing timers.</summary>
|
||||
[Fact]
|
||||
public void Disposed_scheduler_stops_firing()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromMilliseconds(50))]);
|
||||
|
||||
var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromMilliseconds(50))]);
|
||||
sched.Dispose();
|
||||
|
||||
// After dispose, second Start throws ObjectDisposedException.
|
||||
Should.Throw<ObjectDisposedException>(() =>
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromMilliseconds(50))]));
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-015: Start must not be callable twice on the same instance -----
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a second call to Start on the same (non-disposed) scheduler
|
||||
/// throws InvalidOperationException. Without this guard, calling Start twice
|
||||
/// appends a duplicate set of timers and TickGroups, causing every timer-group
|
||||
/// to fire and evaluate twice per interval with no diagnostic.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Start_called_twice_throws_InvalidOperationException()
|
||||
{
|
||||
// Regression for Core.VirtualTags-015: without the _started guard, two Start
|
||||
// calls silently double the timer count and the evaluation frequency.
|
||||
var up = new FakeUpstream();
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
using var engine = new VirtualTagEngine(up,
|
||||
new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(10))]);
|
||||
|
||||
using var sched = new TimerTriggerScheduler(engine, logger);
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(10))]);
|
||||
|
||||
// Second call on the same live instance must throw — not silently create duplicates.
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
sched.Start([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""",
|
||||
TimerInterval: TimeSpan.FromSeconds(10))]));
|
||||
}
|
||||
}
|
||||
@@ -1,800 +0,0 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end VirtualTagEngine behavior: load config, subscribe to upstream,
|
||||
/// evaluate on change, cascade through dependent virtual tags, timer-driven
|
||||
/// re-evaluation, error isolation, historize flag, cycle rejection.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VirtualTagEngineTests
|
||||
{
|
||||
private static VirtualTagEngine Build(
|
||||
FakeUpstream upstream,
|
||||
IHistoryWriter? history = null,
|
||||
TimeSpan? scriptTimeout = null,
|
||||
Func<DateTime>? clock = null)
|
||||
{
|
||||
var rootLogger = new LoggerConfiguration().CreateLogger();
|
||||
return new VirtualTagEngine(
|
||||
upstream,
|
||||
new ScriptLoggerFactory(rootLogger),
|
||||
rootLogger,
|
||||
history,
|
||||
clock,
|
||||
scriptTimeout);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a simple virtual tag script can read an upstream tag and return a coerced value.</summary>
|
||||
[Fact]
|
||||
public async Task Simple_script_reads_upstream_and_returns_coerced_value()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("InTag", 10.0);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
Path: "LineRate",
|
||||
DataType: DriverDataType.Float64,
|
||||
ScriptSource: """return (double)ctx.GetTag("InTag").Value * 2.0;""")]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var result = engine.Read("LineRate");
|
||||
result.StatusCode.ShouldBe(0u);
|
||||
result.Value.ShouldBe(20.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an upstream tag change triggers a cascade re-evaluation through two levels of dependent virtual tags.</summary>
|
||||
[Fact]
|
||||
public async Task Upstream_change_triggers_cascade_through_two_levels()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("A", 1.0);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("B", DriverDataType.Float64,
|
||||
"""return (double)ctx.GetTag("A").Value + 10.0;"""),
|
||||
new VirtualTagDefinition("C", DriverDataType.Float64,
|
||||
"""return (double)ctx.GetTag("B").Value * 2.0;"""),
|
||||
]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("B").Value.ShouldBe(11.0);
|
||||
engine.Read("C").Value.ShouldBe(22.0);
|
||||
|
||||
// Change upstream — cascade should recompute B (11→15.0) then C (30.0).
|
||||
// Both B and C are updated in the same CascadeAsync call (topological order:
|
||||
// B then C), but we must wait for each independently: the WaitForConditionAsync
|
||||
// on B returns as soon as _valueCache["B"] is set (before the semaphore is
|
||||
// released for C's evaluation), so asserting C immediately after the B-wait
|
||||
// races against C's still-in-progress evaluation. Wait for C explicitly.
|
||||
up.Push("A", 5.0);
|
||||
await WaitForConditionAsync(() => Equals(engine.Read("B").Value, 15.0));
|
||||
await WaitForConditionAsync(() => Equals(engine.Read("C").Value, 30.0));
|
||||
engine.Read("B").Value.ShouldBe(15.0);
|
||||
engine.Read("C").Value.ShouldBe(30.0);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a circular dependency among virtual tags is rejected at load time.</summary>
|
||||
[Fact]
|
||||
public async Task Cycle_in_virtual_tags_rejected_at_Load()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
Should.Throw<DependencyCycleException>(() => engine.Load([
|
||||
new VirtualTagDefinition("A", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value + 1;"""),
|
||||
new VirtualTagDefinition("B", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value + 1;"""),
|
||||
]));
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that script compilation errors surface at load time with all failures aggregated.</summary>
|
||||
[Fact]
|
||||
public async Task Script_compile_error_surfaces_at_Load_with_all_failures()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("A", DriverDataType.Int32, """return undefinedIdentifier;"""),
|
||||
new VirtualTagDefinition("B", DriverDataType.Int32, """return 42;"""),
|
||||
new VirtualTagDefinition("C", DriverDataType.Int32, """var x = anotherUndefined; return x;"""),
|
||||
]));
|
||||
ex.Message.ShouldContain("2 script(s) did not compile");
|
||||
ex.Message.ShouldContain("A");
|
||||
ex.Message.ShouldContain("C");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a runtime exception in one virtual tag's script is isolated and does not affect other tags.</summary>
|
||||
[Fact]
|
||||
public async Task Script_runtime_exception_isolates_to_owning_tag()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("OK", 10);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("GoodTag", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("OK").Value * 2;"""),
|
||||
new VirtualTagDefinition("BadTag", DriverDataType.Int32,
|
||||
"""throw new InvalidOperationException("boom");"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("GoodTag").StatusCode.ShouldBe(0u);
|
||||
engine.Read("GoodTag").Value.ShouldBe(20);
|
||||
engine.Read("BadTag").StatusCode.ShouldBe(0x80020000u, "BadInternalError for thrown script");
|
||||
engine.Read("BadTag").Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that script timeout is mapped to BadInternalError status without killing the engine.</summary>
|
||||
[Fact]
|
||||
public async Task Timeout_maps_to_BadInternalError_without_killing_the_engine()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up, scriptTimeout: TimeSpan.FromMilliseconds(30));
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Hang", DriverDataType.Int32, """
|
||||
var end = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < end) { }
|
||||
return 1;
|
||||
"""),
|
||||
new VirtualTagDefinition("Ok", DriverDataType.Int32, """return 42;"""),
|
||||
]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Hang").StatusCode.ShouldBe(0x80020000u);
|
||||
engine.Read("Ok").Value.ShouldBe(42);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribers receive notifications when the engine emits value changes.</summary>
|
||||
[Fact]
|
||||
public async Task Subscribers_receive_engine_emitted_changes()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value + 100;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var received = new List<DataValueSnapshot>();
|
||||
using var sub = engine.Subscribe("Out", (p, v) => received.Add(v));
|
||||
|
||||
up.Push("In", 5);
|
||||
await WaitForConditionAsync(() => received.Count >= 1);
|
||||
|
||||
received[^1].Value.ShouldBe(105);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the historize flag routes virtual tag values to the history writer.</summary>
|
||||
[Fact]
|
||||
public async Task Historize_flag_routes_to_history_writer()
|
||||
{
|
||||
var recorded = new List<(string, DataValueSnapshot)>();
|
||||
var history = new TestHistory(recorded);
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up, history);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("H", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value + 1;""", Historize: true),
|
||||
new VirtualTagDefinition("NoH", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value - 1;""", Historize: false),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
recorded.Select(p => p.Item1).ShouldContain("H");
|
||||
recorded.Select(p => p.Item1).ShouldNotContain("NoH");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that upstream pushes are ignored when change-driven is false.</summary>
|
||||
[Fact]
|
||||
public async Task Change_driven_false_ignores_upstream_push()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Manual", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value * 10;""",
|
||||
ChangeTriggered: false)]);
|
||||
|
||||
// Initial eval seeds the value.
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Manual").Value.ShouldBe(10);
|
||||
|
||||
// Upstream change fires but change-driven is off — no recompute.
|
||||
up.Push("In", 99);
|
||||
await Task.Delay(100);
|
||||
engine.Read("Manual").Value.ShouldBe(10, "change-driven=false ignores upstream deltas");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that reloading the engine replaces existing tags and resubscribes to upstream sources cleanly.</summary>
|
||||
[Fact]
|
||||
public async Task Reload_replaces_existing_tags_and_resubscribes_cleanly()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("A", 1);
|
||||
up.Set("B", 2);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value * 2;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("T").Value.ShouldBe(2);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
// Reload — T now depends on B instead of A.
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return (int)ctx.GetTag("B").Value * 3;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("T").Value.ShouldBe(6);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1, "previous subscription on A must be disposed");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that disposing the engine releases all upstream subscriptions.</summary>
|
||||
[Fact]
|
||||
public async Task Dispose_releases_upstream_subscriptions()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("A", 1);
|
||||
var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return (int)ctx.GetTag("A").Value;""")]);
|
||||
up.ActiveSubscriptionCount.ShouldBe(1);
|
||||
|
||||
engine.Dispose();
|
||||
up.ActiveSubscriptionCount.ShouldBe(0);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SetVirtualTag called within a script updates the target and triggers observers.</summary>
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_within_script_updates_target_and_triggers_observers()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 5);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Target", DriverDataType.Int32,
|
||||
"""return 0;""", ChangeTriggered: false), // placeholder value, operator-written via SetVirtualTag
|
||||
new VirtualTagDefinition("Driver", DriverDataType.Int32,
|
||||
"""
|
||||
var v = (int)ctx.GetTag("In").Value;
|
||||
ctx.SetVirtualTag("Target", v * 100);
|
||||
return v;
|
||||
"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("Target").Value.ShouldBe(500);
|
||||
engine.Read("Driver").Value.ShouldBe(5);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SetVirtualTag within a script cascades to change-triggered dependents of the written tag.</summary>
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_within_script_cascades_to_dependents_of_the_written_tag()
|
||||
{
|
||||
// Regression for Core.VirtualTags-001: a ctx.SetVirtualTag write must schedule
|
||||
// the change-trigger cascade for tags depending on the written path, exactly
|
||||
// like an upstream delta. "Dependent" reads "Target" and is ChangeTriggered, so
|
||||
// a write to Target via ctx.SetVirtualTag must re-evaluate Dependent.
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 5);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Target", DriverDataType.Int32,
|
||||
"""return 0;""", ChangeTriggered: false), // operator-written via SetVirtualTag
|
||||
new VirtualTagDefinition("Dependent", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("Target").Value + 1;""", ChangeTriggered: true),
|
||||
new VirtualTagDefinition("Writer", DriverDataType.Int32,
|
||||
"""
|
||||
var v = (int)ctx.GetTag("In").Value;
|
||||
ctx.SetVirtualTag("Target", v * 100);
|
||||
return v;
|
||||
"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Writer set Target = 500; the cascade must have re-evaluated Dependent to 501.
|
||||
engine.Read("Target").Value.ShouldBe(500);
|
||||
await WaitForConditionAsync(() => Equals(engine.Read("Dependent").Value, 501));
|
||||
engine.Read("Dependent").Value.ShouldBe(501,
|
||||
"a ctx.SetVirtualTag write must cascade to change-triggered dependents");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that type coercion from script double to configured int32 works correctly.</summary>
|
||||
[Fact]
|
||||
public async Task Type_coercion_from_script_double_to_config_int32()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 3.7);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Rounded", DriverDataType.Int32,
|
||||
"""return (double)ctx.GetTag("In").Value;""")]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("Rounded").Value.ShouldBe(4, "Convert.ToInt32 rounds 3.7 to 4");
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-012: previously-missing coverage -----
|
||||
|
||||
/// <summary>Verifies that the AreInputsReady guard publishes BadWaitingForInitialData when upstream tags have bad status.</summary>
|
||||
[Fact]
|
||||
public async Task AreInputsReady_guard_publishes_BadWaitingForInitialData_when_upstream_is_bad()
|
||||
{
|
||||
// Arrange: upstream tag is Bad-quality (not yet available).
|
||||
var up = new FakeUpstream();
|
||||
up.Set("BadIn", null!, 0x80000000u); // bad status, null value
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Derived", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("BadIn").Value * 2;""")]);
|
||||
|
||||
// Act: evaluate — inputs are not ready.
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Assert: tag publishes BadWaitingForInitialData, not a stale null/Good.
|
||||
var result = engine.Read("Derived");
|
||||
result.StatusCode.ShouldBe(0x80320000u, "BadWaitingForInitialData expected when inputs are bad");
|
||||
result.Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the AreInputsReady guard recovers when an upstream tag transitions from bad to good status.</summary>
|
||||
[Fact]
|
||||
public async Task AreInputsReady_guard_publishes_BadWaitingForInitialData_then_recovers_when_upstream_becomes_good()
|
||||
{
|
||||
// Arrange: upstream tag starts absent (null/Bad).
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Derived", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("In").Value + 1;""")]);
|
||||
|
||||
// First evaluation: upstream not ready → BadWaitingForInitialData.
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Derived").StatusCode.ShouldBe(0x80320000u);
|
||||
|
||||
// Upstream becomes available.
|
||||
up.Push("In", 10);
|
||||
await WaitForConditionAsync(() => engine.Read("Derived").StatusCode == 0u);
|
||||
|
||||
// Tag should now have a Good value.
|
||||
engine.Read("Derived").StatusCode.ShouldBe(0u);
|
||||
engine.Read("Derived").Value.ShouldBe(11);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SetVirtualTag cascades to change-triggered dependents.</summary>
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_cascades_to_change_triggered_dependent()
|
||||
{
|
||||
// Arrange: "Writer" writes to "Target"; "Consumer" reads "Target" and is change-triggered.
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 3);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Target", DriverDataType.Int32,
|
||||
"""return 0;""", ChangeTriggered: false),
|
||||
new VirtualTagDefinition("Writer", DriverDataType.Int32,
|
||||
"""
|
||||
var v = (int)ctx.GetTag("In").Value;
|
||||
ctx.SetVirtualTag("Target", v * 10);
|
||||
return v;
|
||||
"""),
|
||||
new VirtualTagDefinition("Consumer", DriverDataType.Int32,
|
||||
"""return (int)ctx.GetTag("Target").Value + 1;""",
|
||||
ChangeTriggered: true),
|
||||
]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// Writer sets Target = 30; Consumer should cascade and compute 31.
|
||||
await WaitForConditionAsync(() => engine.Read("Consumer").Value is int v && v == 31);
|
||||
engine.Read("Target").Value.ShouldBe(30);
|
||||
engine.Read("Consumer").Value.ShouldBe(31);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that SetVirtualTag calls targeting unregistered paths are caught at load time.</summary>
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_on_non_registered_path_is_caught_at_Load()
|
||||
{
|
||||
// Originally validated the runtime warning-and-drop branch in OnScriptSetVirtualTag.
|
||||
// After Core.VirtualTags-011 the static DependencyExtractor.Writes set is validated
|
||||
// at Load time, so a literal-string write to a non-existent path is now rejected
|
||||
// at publish — the dynamic warning path is reserved as a defensive guard for cases
|
||||
// the static extractor cannot see (currently none, since dynamic paths are also
|
||||
// rejected at extraction).
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("Writer", DriverDataType.Int32,
|
||||
"""
|
||||
ctx.SetVirtualTag("NonExistentPath", 99);
|
||||
return (int)ctx.GetTag("In").Value;
|
||||
""")
|
||||
])).Message.ShouldContain("NonExistentPath");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that EvaluateOneAsync throws ArgumentException when called for an unregistered path.</summary>
|
||||
[Fact]
|
||||
public async Task EvaluateOneAsync_throws_ArgumentException_for_unregistered_path()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition("A", DriverDataType.Int32, """return 1;""")]);
|
||||
|
||||
await Should.ThrowAsync<ArgumentException>(async () =>
|
||||
await engine.EvaluateOneAsync("NoSuchTag", TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a type coercion failure maps to BadInternalError status.</summary>
|
||||
[Fact]
|
||||
public async Task CoerceResult_failure_maps_to_BadInternalError()
|
||||
{
|
||||
// Arrange: script returns an object that cannot be coerced to the declared type.
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Bad", DriverDataType.Int32,
|
||||
// Return a non-numeric string — Convert.ToInt32("not-a-number") throws.
|
||||
"""return "not-a-number";""")]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// CoerceResult returns null on failure; the null propagates as BadInternalError.
|
||||
engine.Read("Bad").StatusCode.ShouldBe(0x80020000u, "type-coercion failure must map to BadInternalError");
|
||||
engine.Read("Bad").Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-011: Writes target validation at Load time -----
|
||||
|
||||
/// <summary>Verifies that Load rejects scripts that write to unregistered virtual tag paths.</summary>
|
||||
[Fact]
|
||||
public async Task Load_rejects_script_writing_to_unregistered_virtual_tag_path()
|
||||
{
|
||||
// Regression for Core.VirtualTags-011: a script that calls
|
||||
// ctx.SetVirtualTag("Typo", ...) must be caught at publish/load time rather than
|
||||
// silently dropped at runtime, so operator typos surface as a publish failure.
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("Writer", DriverDataType.Int32,
|
||||
"""
|
||||
ctx.SetVirtualTag("NonRegisteredTarget", 1);
|
||||
return 0;
|
||||
"""),
|
||||
new VirtualTagDefinition("RegisteredTarget", DriverDataType.Int32,
|
||||
"""return 1;"""),
|
||||
]));
|
||||
ex.Message.ShouldContain("Writer");
|
||||
ex.Message.ShouldContain("NonRegisteredTarget");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Load accepts scripts that write to registered virtual tag paths.</summary>
|
||||
[Fact]
|
||||
public async Task Load_accepts_script_writing_to_registered_virtual_tag_path()
|
||||
{
|
||||
// Companion to the rejection test: a write to a registered tag must continue to
|
||||
// load successfully.
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
|
||||
// No throw — Writer writes to Target which is registered.
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("Target", DriverDataType.Int32,
|
||||
"""return 0;""", ChangeTriggered: false),
|
||||
new VirtualTagDefinition("Writer", DriverDataType.Int32,
|
||||
"""
|
||||
ctx.SetVirtualTag("Target", (int)ctx.GetTag("In").Value);
|
||||
return 0;
|
||||
"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Target").Value.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-006: empty observer list left in _observers map -----
|
||||
|
||||
/// <summary>Verifies that disposing the last subscriber for a path removes the empty observer list entry.</summary>
|
||||
[Fact]
|
||||
public void Subscribe_then_Unsub_prunes_empty_observer_list_for_path()
|
||||
{
|
||||
// Regression for Core.VirtualTags-006: disposing the last subscriber for a path
|
||||
// must remove the dictionary entry so a long-running server with churning OPC UA
|
||||
// subscriptions does not accumulate an unbounded number of empty List entries.
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"T", DriverDataType.Int32, """return 1;""")]);
|
||||
|
||||
// Subscribe, then immediately Dispose — both the only observer.
|
||||
var sub1 = engine.Subscribe("T", (_, _) => { });
|
||||
var sub2 = engine.Subscribe("T", (_, _) => { });
|
||||
sub1.Dispose();
|
||||
sub2.Dispose();
|
||||
|
||||
// The internal map should no longer hold an entry for the path.
|
||||
// Use the same ConcurrentDictionary type the engine uses; we check via reflection
|
||||
// on the test-private field so this is robust to future renames inside engine.
|
||||
var observersField = typeof(VirtualTagEngine).GetField(
|
||||
"_observers",
|
||||
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
observersField.ShouldNotBeNull();
|
||||
var observers = observersField!.GetValue(engine);
|
||||
observers.ShouldNotBeNull();
|
||||
var containsKey = observers!.GetType().GetMethod("ContainsKey")!;
|
||||
var result = (bool)containsKey.Invoke(observers, new object[] { "T" })!;
|
||||
result.ShouldBeFalse("disposing the last subscriber must remove the dictionary entry");
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-004: CoerceResult default arm leaks uncoerced values -----
|
||||
|
||||
/// <summary>Verifies that CoerceResult correctly handles Int16, UInt16, UInt32, and UInt64 types.</summary>
|
||||
[Fact]
|
||||
public async Task CoerceResult_handles_Int16_UInt16_UInt32_UInt64()
|
||||
{
|
||||
// Regression for Core.VirtualTags-004: before the fix, CoerceResult had a default
|
||||
// arm that returned the script's raw double/string for these types, producing a
|
||||
// type-mismatched DataValueSnapshot. Verify every integer DriverDataType the engine
|
||||
// is allowed to declare coerces correctly.
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
new VirtualTagDefinition("AsInt16", DriverDataType.Int16, """return 7.0;"""),
|
||||
new VirtualTagDefinition("AsUInt16", DriverDataType.UInt16, """return 8.0;"""),
|
||||
new VirtualTagDefinition("AsUInt32", DriverDataType.UInt32, """return 9.0;"""),
|
||||
new VirtualTagDefinition("AsUInt64", DriverDataType.UInt64, """return 10.0;"""),
|
||||
]);
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
engine.Read("AsInt16").Value.ShouldBeOfType<short>();
|
||||
engine.Read("AsInt16").Value.ShouldBe((short)7);
|
||||
engine.Read("AsUInt16").Value.ShouldBeOfType<ushort>();
|
||||
engine.Read("AsUInt16").Value.ShouldBe((ushort)8);
|
||||
engine.Read("AsUInt32").Value.ShouldBeOfType<uint>();
|
||||
engine.Read("AsUInt32").Value.ShouldBe((uint)9);
|
||||
engine.Read("AsUInt64").Value.ShouldBeOfType<ulong>();
|
||||
engine.Read("AsUInt64").Value.ShouldBe((ulong)10);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Load rejects virtual tag definitions with unsupported DriverDataType values.</summary>
|
||||
[Fact]
|
||||
public async Task Load_rejects_definition_with_unsupported_DriverDataType()
|
||||
{
|
||||
// Regression for Core.VirtualTags-004: any DriverDataType that CoerceResult cannot
|
||||
// honour must be rejected at Load time so an operator typo (or a future enum
|
||||
// member added without coercion support) does not silently emit a type-mismatched
|
||||
// value to OPC UA clients. Reference is unsupported for virtual tags (the engine
|
||||
// does not synthesize Galaxy attribute references).
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("Ref", DriverDataType.Reference, """return "Some.Attribute";"""),
|
||||
]));
|
||||
ex.Message.ShouldContain("Reference");
|
||||
ex.Message.ShouldContain("Ref");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Load rejects duplicate virtual tag paths with an aggregated error message.</summary>
|
||||
[Fact]
|
||||
public async Task Load_rejects_duplicate_path_with_aggregated_error()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
var ex = Should.Throw<InvalidOperationException>(() => engine.Load([
|
||||
new VirtualTagDefinition("Dup", DriverDataType.Int32, """return 1;"""),
|
||||
new VirtualTagDefinition("Dup", DriverDataType.Int32, """return 2;"""),
|
||||
new VirtualTagDefinition("Good", DriverDataType.Int32, """return 3;"""),
|
||||
]));
|
||||
ex.Message.ShouldContain("Dup");
|
||||
ex.Message.ShouldContain("duplicate");
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Read returns BadNodeIdUnknown for all paths before Load is called.</summary>
|
||||
[Fact]
|
||||
public void Read_before_Load_returns_BadNodeIdUnknown()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
// Read is allowed before Load — it just returns BadNodeIdUnknown for everything.
|
||||
var result = engine.Read("AnyPath");
|
||||
result.StatusCode.ShouldBe(0x80340000u, "BadNodeIdUnknown before Load");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that EvaluateOneAsync throws InvalidOperationException when called before Load.</summary>
|
||||
[Fact]
|
||||
public void EvaluateOneAsync_before_Load_throws_InvalidOperationException()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
Should.Throw<InvalidOperationException>(() =>
|
||||
engine.EvaluateOneAsync("A").GetAwaiter().GetResult());
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Subscribe does not throw when called before Load.</summary>
|
||||
[Fact]
|
||||
public void Subscribe_before_Load_does_not_throw()
|
||||
{
|
||||
// Subscribe uses GetOrAdd and does not call EnsureLoaded — it should work
|
||||
// (returns an Unsub handle) without a Load. The observer won't fire because
|
||||
// no tag is registered, but it must not throw.
|
||||
var up = new FakeUpstream();
|
||||
using var engine = Build(up);
|
||||
|
||||
var fired = false;
|
||||
var sub = engine.Subscribe("AnyPath", (_, _) => fired = true);
|
||||
sub.ShouldNotBeNull();
|
||||
sub.Dispose();
|
||||
fired.ShouldBeFalse("no evaluation has happened, observer must not fire");
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-014: AreInputsReady must not block on Good-quality null value -----
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a Good-quality upstream snapshot with a null Value (e.g. a
|
||||
/// script returning null) does not permanently block downstream change-triggered
|
||||
/// dependents at BadWaitingForInitialData. A Good-quality null means "the
|
||||
/// upstream is online and returned null" — readiness is determined by StatusCode,
|
||||
/// not by the value's nullability.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AreInputsReady_Good_quality_null_upstream_does_not_block_downstream()
|
||||
{
|
||||
// Regression for Core.VirtualTags-014: before the fix, AreInputsReady checked
|
||||
// kv.Value.Value is null, so a Good-quality DataValueSnapshot(null, 0u, ...) —
|
||||
// produced by an upstream script returning null — kept every downstream stuck at
|
||||
// BadWaitingForInitialData forever. The fix gates only on StatusCode (bit 31).
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([
|
||||
// Producer script returns null — stores DataValueSnapshot(null, Good, ...).
|
||||
new VirtualTagDefinition("NullProducer", DriverDataType.String,
|
||||
"""return null;"""),
|
||||
// Consumer depends on NullProducer and is change-triggered.
|
||||
// With the bug: Consumer is stuck at BadWaitingForInitialData.
|
||||
// After the fix: Consumer evaluates and surfaces the NRE as BadInternalError
|
||||
// (since it casts null unconditionally), which is the correct sentinel for
|
||||
// "the script ran but produced an error" rather than "inputs not ready".
|
||||
new VirtualTagDefinition("Consumer", DriverDataType.String,
|
||||
"""return (string)ctx.GetTag("NullProducer").Value + "_suffix";""",
|
||||
ChangeTriggered: true),
|
||||
]);
|
||||
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
// NullProducer should be Good with null value (script returned null).
|
||||
var producerResult = engine.Read("NullProducer");
|
||||
producerResult.StatusCode.ShouldBe(0u, "Good quality: script returned null cleanly");
|
||||
producerResult.Value.ShouldBeNull();
|
||||
|
||||
// Consumer must NOT be stuck at BadWaitingForInitialData — it should have
|
||||
// evaluated (and produced BadInternalError from the cast of null + "_suffix").
|
||||
var consumerResult = engine.Read("Consumer");
|
||||
consumerResult.StatusCode.ShouldNotBe(0x80320000u,
|
||||
"Consumer must not be stuck at BadWaitingForInitialData when its upstream is Good-quality");
|
||||
}
|
||||
|
||||
private static async Task WaitForConditionAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (cond()) return;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
throw new TimeoutException("Condition did not become true in time");
|
||||
}
|
||||
|
||||
/// <summary>Test implementation of IHistoryWriter that records values to a buffer.</summary>
|
||||
private sealed class TestHistory : IHistoryWriter
|
||||
{
|
||||
private readonly List<(string, DataValueSnapshot)> _buf;
|
||||
|
||||
/// <summary>Initializes a new instance of the TestHistory class with the specified buffer.</summary>
|
||||
/// <param name="buf">The list to store recorded history entries.</param>
|
||||
public TestHistory(List<(string, DataValueSnapshot)> buf) => _buf = buf;
|
||||
|
||||
/// <summary>Records a virtual tag path and value snapshot to the history buffer.</summary>
|
||||
/// <param name="path">The virtual tag path.</param>
|
||||
/// <param name="value">The data value snapshot to record.</param>
|
||||
public void Record(string path, DataValueSnapshot value)
|
||||
{
|
||||
lock (_buf) { _buf.Add((path, value)); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Core.Scripting-016: engine routes compiles through CompiledScriptCache ---
|
||||
|
||||
/// <summary>Verifies that disposing the engine unloads the compiled script assembly.</summary>
|
||||
[Fact]
|
||||
public void Dispose_unloads_compiled_script_assembly()
|
||||
{
|
||||
// Pre-fix the engine called ScriptEvaluator.Compile directly, so the emitted
|
||||
// script assembly's ALC stayed loaded for the process lifetime. After the fix
|
||||
// the engine routes through CompiledScriptCache; engine Dispose triggers cache
|
||||
// Dispose which unloads every cached evaluator's ALC. Assert via WeakReference
|
||||
// + GC that the assembly is actually reclaimed.
|
||||
var weak = CompileTagAndCaptureWeak();
|
||||
for (int i = 0; i < 10 && weak.IsAlive; i++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
}
|
||||
weak.IsAlive.ShouldBeFalse(
|
||||
"engine Dispose must release the compiled-tag assembly via " +
|
||||
"CompiledScriptCache (Core.Scripting-016). If this fails the engine is " +
|
||||
"back to calling ScriptEvaluator.Compile directly and -008's headline " +
|
||||
"fix doesn't run in production.");
|
||||
}
|
||||
|
||||
[System.Runtime.CompilerServices.MethodImpl(
|
||||
System.Runtime.CompilerServices.MethodImplOptions.NoInlining)]
|
||||
private static WeakReference CompileTagAndCaptureWeak()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("X", 10);
|
||||
var engine = Build(up);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
Path: "Doubled",
|
||||
DataType: DriverDataType.Float64,
|
||||
ScriptSource: """return (double)ctx.GetTag("X").Value * 2.0;""")]);
|
||||
|
||||
// Reach into the engine's compile cache via reflection — internals scoped to
|
||||
// Tests-via-InternalsVisibleTo and the field is private.
|
||||
var cacheField = typeof(VirtualTagEngine).GetField(
|
||||
"_compileCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
var cache = cacheField.GetValue(engine)!;
|
||||
var cacheDictField = cache.GetType().GetField(
|
||||
"_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
var cacheDict = (System.Collections.IDictionary)cacheDictField.GetValue(cache)!;
|
||||
var lazy = cacheDict.Values.Cast<object>().Single();
|
||||
var lazyValueProp = lazy.GetType().GetProperty("Value")!;
|
||||
var evaluator = lazyValueProp.GetValue(lazy)!;
|
||||
var funcField = evaluator.GetType().GetField(
|
||||
"_func", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
|
||||
var del = (Delegate)funcField.GetValue(evaluator)!;
|
||||
var weak = new WeakReference(del.Method.Module.Assembly);
|
||||
|
||||
engine.Dispose();
|
||||
return weak;
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
using Serilog;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the IReadable + ISubscribable adapter that DriverNodeManager dispatches
|
||||
/// to for NodeSource.Virtual per ADR-002. Key contract: OPC UA clients see virtual
|
||||
/// tags via the same capability interfaces as driver tags, so dispatch stays
|
||||
/// source-agnostic.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class VirtualTagSourceTests
|
||||
{
|
||||
private static (VirtualTagEngine engine, VirtualTagSource source, FakeUpstream up) Build()
|
||||
{
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 10);
|
||||
var logger = new LoggerConfiguration().CreateLogger();
|
||||
var engine = new VirtualTagEngine(up, new ScriptLoggerFactory(logger), logger);
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Out", DriverDataType.Int32, """return (int)ctx.GetTag("In").Value * 2;""")]);
|
||||
return (engine, new VirtualTagSource(engine), up);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that ReadAsync returns cached engine values.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_engine_cached_values()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var results = await source.ReadAsync(["Out"], TestContext.Current.CancellationToken);
|
||||
results.Count.ShouldBe(1);
|
||||
results[0].Value.ShouldBe(20);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unknown paths return Bad status quality.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_unknown_path_returns_Bad_quality()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
var results = await source.ReadAsync(["NoSuchTag"], TestContext.Current.CancellationToken);
|
||||
results[0].StatusCode.ShouldBe(0x80340000u);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscribe fires the initial data callback immediately.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_fires_initial_data_callback()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<DataChangeEventArgs>();
|
||||
source.OnDataChange += (_, e) => events.Add(e);
|
||||
|
||||
var handle = await source.SubscribeAsync(["Out"], TimeSpan.FromMilliseconds(100),
|
||||
TestContext.Current.CancellationToken);
|
||||
handle.ShouldNotBeNull();
|
||||
|
||||
// Per OPC UA convention, initial-data callback fires on subscribe.
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
events[0].FullReference.ShouldBe("Out");
|
||||
events[0].Snapshot.Value.ShouldBe(20);
|
||||
|
||||
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that subscription fires on upstream changes via engine cascade.</summary>
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_fires_on_upstream_change_via_engine_cascade()
|
||||
{
|
||||
var (engine, source, up) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<DataChangeEventArgs>();
|
||||
source.OnDataChange += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
var initialCount = events.Count;
|
||||
up.Push("In", 50);
|
||||
|
||||
// Wait for the cascade.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (DateTime.UtcNow < deadline && events.Count <= initialCount) await Task.Delay(25);
|
||||
|
||||
events.Count.ShouldBeGreaterThan(initialCount);
|
||||
events[^1].Snapshot.Value.ShouldBe(100);
|
||||
|
||||
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that unsubscribe stops further event emissions.</summary>
|
||||
[Fact]
|
||||
public async Task UnsubscribeAsync_stops_further_events()
|
||||
{
|
||||
var (engine, source, up) = Build();
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var events = new List<DataChangeEventArgs>();
|
||||
source.OnDataChange += (_, e) => events.Add(e);
|
||||
var handle = await source.SubscribeAsync(["Out"], TimeSpan.Zero,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
await source.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
var countAfterUnsub = events.Count;
|
||||
|
||||
up.Push("In", 99);
|
||||
await Task.Delay(200);
|
||||
|
||||
events.Count.ShouldBe(countAfterUnsub, "Unsubscribe must stop OnDataChange emissions");
|
||||
engine.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Verifies that null arguments are rejected.</summary>
|
||||
[Fact]
|
||||
public async Task Null_arguments_rejected()
|
||||
{
|
||||
var (engine, source, _) = Build();
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.ReadAsync(null!, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.SubscribeAsync(null!, TimeSpan.Zero, TestContext.Current.CancellationToken));
|
||||
await Should.ThrowAsync<ArgumentNullException>(async () =>
|
||||
await source.UnsubscribeAsync(null!, TestContext.Current.CancellationToken));
|
||||
engine.Dispose();
|
||||
}
|
||||
}
|
||||
-5
@@ -7,11 +7,6 @@
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Core.VirtualTags.Tests</RootNamespace>
|
||||
<!-- OTOPCUA0001 (arch-review 07/C-1): these tests invoke VirtualTagSource's IReadable/ISubscribable
|
||||
methods DIRECTLY to exercise its wire-level behavior, which is the analyzer's own documented
|
||||
intentional case ("a unit test invoking the driver directly ... move the suppression into a
|
||||
NoWarn for the test project"). Genuinely intentional — not a resilience-dispatch gap. -->
|
||||
<NoWarn>$(NoWarn);OTOPCUA0001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
|
||||
public sealed class LastValueCacheTests
|
||||
{
|
||||
// OPC UA BadWaitingForInitialData (0x80320000) — the repo-wide convention for "no value
|
||||
// observed yet" (mirrored by CalculationDriver, VirtualTagEngine, FOCAS, and
|
||||
// observed yet" (mirrored by CalculationDriver, FOCAS, and
|
||||
// OtOpcUaNodeManager for freshly-materialised / not-yet-evaluated nodes). MQTT is
|
||||
// subscribe-first, so an unseen RawPath is in exactly that state until its first publish.
|
||||
private const uint BadWaitingForInitialData = 0x80320000u;
|
||||
|
||||
Reference in New Issue
Block a user