test(vtags): thread-safe CapturingHistoryWriter + drop redundant wait (H5c review follow-up)

This commit is contained in:
Joseph Doherty
2026-06-15 10:33:14 -04:00
parent 0c6d4c5491
commit 2f30c54dc1
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Akka.Actor; using Akka.Actor;
using Shouldly; using Shouldly;
using Xunit; using Xunit;
@@ -191,7 +192,8 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
// Wait for the history record to land (delivered on the same actor turn as the publish). // Wait for the history record to land (delivered on the same actor turn as the publish).
AwaitAssert(() => writer.Calls.Count.ShouldBe(1)); AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
var (path, value) = writer.Calls[0]; writer.Calls.TryPeek(out var captured).ShouldBeTrue();
var (path, value) = captured;
path.ShouldBe("eq-1/speed-rpm"); path.ShouldBe("eq-1/speed-rpm");
value.Value.ShouldBe(42.0); value.Value.ShouldBe(42.0);
value.StatusCode.ShouldBe(0u); // OPC UA Good value.StatusCode.ShouldBe(0u); // OPC UA Good
@@ -213,11 +215,10 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, DateTime.UtcNow, CorrelationId.NewId())); host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, DateTime.UtcNow, CorrelationId.NewId()));
// The value is still published // The value is still published — and the publish completing means OnResult's turn is done,
// so the historian (which would have been called on the same turn) was never touched.
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(); publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
// …but the historian was never touched. writer.Calls.IsEmpty.ShouldBeTrue();
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
writer.Calls.ShouldBeEmpty();
} }
/// <summary>A plan with an explicit Expression + DependencyRefs (the H1b in-place-change case).</summary> /// <summary>A plan with an explicit Expression + DependencyRefs (the H1b in-place-change case).</summary>
@@ -348,12 +349,14 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
/// assert the host historizes (or does not) and with what path + snapshot.</summary> /// assert the host historizes (or does not) and with what path + snapshot.</summary>
private sealed class CapturingHistoryWriter : IHistoryWriter private sealed class CapturingHistoryWriter : IHistoryWriter
{ {
public readonly List<(string Path, DataValueSnapshot Value)> Calls = new(); // ConcurrentQueue: Record runs on the actor thread, the test asserts on the test thread —
// a plain List would be a cross-thread data race under load.
public readonly ConcurrentQueue<(string Path, DataValueSnapshot Value)> Calls = new();
/// <summary>Captures the path + snapshot of a Record call.</summary> /// <summary>Captures the path + snapshot of a Record call.</summary>
/// <param name="path">The virtual tag path.</param> /// <param name="path">The virtual tag path.</param>
/// <param name="value">The data value snapshot.</param> /// <param name="value">The data value snapshot.</param>
public void Record(string path, DataValueSnapshot value) => Calls.Add((path, value)); public void Record(string path, DataValueSnapshot value) => Calls.Enqueue((path, value));
} }
/// <summary>Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's /// <summary>Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's