feat(mqtt): last-value cache backing IReadable (per-ref no-data)

LastValueCache bridges MQTT's subscribe-first push model to the OPC UA
server's polled IReadable.ReadAsync: the subscription path calls Update()
per RawPath, the read path calls Read(). Read never throws; an unseen
RawPath returns BadWaitingForInitialData (0x80320000) rather than an
exception or null, so a batch covering many references degrades per-ref
instead of failing wholesale.

Deviates from the plan's GoodNoData snippet: GoodNoData is reserved
repo-wide for "the historian window held no samples" (NullHistorianDataSource,
OtOpcUaNodeManager HistoryRead paths); BadWaitingForInitialData is the
established convention for "no live value observed yet" (CalculationDriver,
VirtualTagEngine, FOCAS, AddressSpaceApplier). Keyed by RawPath per the v3
driver-reference identity, not a topic/JSON-path-derived key.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 14:40:15 -04:00
parent 5629f3698d
commit cacc30a60d
2 changed files with 151 additions and 0 deletions
@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
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
// 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;
[Fact]
public void Read_UnseenRef_ReturnsPerRefWaitingForInitialData_DoesNotThrow()
{
var cache = new LastValueCache();
var snap = cache.Read("factory/oven/temp");
snap.StatusCode.ShouldBe(BadWaitingForInitialData);
snap.Value.ShouldBeNull();
}
[Fact]
public void Update_ThenRead_ReturnsLastValue()
{
var cache = new LastValueCache();
var now = DateTime.UtcNow;
cache.Update("factory/oven/temp", new DataValueSnapshot(42.0, 0u, now, now));
var snap = cache.Read("factory/oven/temp");
snap.Value.ShouldBe(42.0);
snap.StatusCode.ShouldBe(0u);
}
[Fact]
public void Update_OverwritesPreviousValue_ForSameRawPath()
{
var cache = new LastValueCache();
var t1 = DateTime.UtcNow;
var t2 = t1.AddSeconds(1);
cache.Update("factory/oven/temp", new DataValueSnapshot(1.0, 0u, t1, t1));
cache.Update("factory/oven/temp", new DataValueSnapshot(2.0, 0u, t2, t2));
cache.Read("factory/oven/temp").Value.ShouldBe(2.0);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void Read_NullOrEmptyRawPath_DoesNotThrow_ReturnsWaitingForInitialData(string? rawPath)
{
var cache = new LastValueCache();
var snap = cache.Read(rawPath!);
snap.StatusCode.ShouldBe(BadWaitingForInitialData);
}
[Fact]
public void Update_NullRawPath_DoesNotThrow()
{
var cache = new LastValueCache();
Should.NotThrow(() => cache.Update(null!, new DataValueSnapshot(1.0, 0u, DateTime.UtcNow, DateTime.UtcNow)));
}
[Fact]
public void Read_DistinguishesUnseenFromGenuinelyBadObservedValue()
{
var cache = new LastValueCache();
var now = DateTime.UtcNow;
const uint badDeviceFailure = 0x808B0000u;
cache.Update("factory/oven/temp", new DataValueSnapshot(null, badDeviceFailure, null, now));
var observedBad = cache.Read("factory/oven/temp");
var neverObserved = cache.Read("factory/oven/other");
observedBad.StatusCode.ShouldBe(badDeviceFailure);
neverObserved.StatusCode.ShouldBe(BadWaitingForInitialData);
observedBad.StatusCode.ShouldNotBe(neverObserved.StatusCode);
}
}