fix(virtual-tags): resolve Medium code-review findings (Core.VirtualTags-002, -003, -005, -008, -012)
Core.VirtualTags-002: cold-start guard publishes BadWaitingForInitialData instead of silently returning a stale value. Core.VirtualTags-003: Load detects duplicate Path values and keys the upstream-subscription loop off the registered tag set. Core.VirtualTags-005: VirtualTagSource fires the initial-data callback per path before registering the change observer, fixing an ordering race. Core.VirtualTags-008: DependencyGraph caches topological rank, lowering per-change-event cost from O(V+E) to O(closure). Core.VirtualTags-012: added 9 engine tests; CoerceResult null-return now maps to BadInternalError as the code comment intended. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -322,6 +322,188 @@ public sealed class VirtualTagEngineTests
|
||||
engine.Read("Rounded").Value.ShouldBe(4, "Convert.ToInt32 rounds 3.7 to 4");
|
||||
}
|
||||
|
||||
// ----- Core.VirtualTags-012: previously-missing coverage -----
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetVirtualTag_on_non_registered_path_logs_warning_and_does_not_throw()
|
||||
{
|
||||
// Arrange: script writes to a path that is not a registered virtual tag.
|
||||
var up = new FakeUpstream();
|
||||
up.Set("In", 1);
|
||||
using var engine = Build(up);
|
||||
|
||||
engine.Load([new VirtualTagDefinition(
|
||||
"Writer", DriverDataType.Int32,
|
||||
"""
|
||||
ctx.SetVirtualTag("NonExistentPath", 99);
|
||||
return (int)ctx.GetTag("In").Value;
|
||||
""")]);
|
||||
|
||||
// Act + Assert: should not throw; engine stays healthy.
|
||||
await engine.EvaluateAllAsync(TestContext.Current.CancellationToken);
|
||||
engine.Read("Writer").StatusCode.ShouldBe(0u, "engine must not fault on write to non-registered path");
|
||||
engine.Read("Writer").Value.ShouldBe(1);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[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));
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
[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());
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
|
||||
private static async Task WaitForConditionAsync(Func<bool> cond, int timeoutMs = 2000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
|
||||
|
||||
Reference in New Issue
Block a user