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:
Joseph Doherty
2026-10-08 06:51:22 -04:00
parent dc54b883eb
commit a8703c6afe
21 changed files with 27 additions and 2181 deletions
@@ -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();
}
}
@@ -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;