fix(mtconnect): bind RawTags and key the data plane by RawPath

The driver could not receive data after a real deploy. The runtime hands every
driver its authored tags as a RawTags array (RawPath identity + TagConfig blob,
injected by DriverDeviceConfigMerger) and then subscribes, reads, and routes
published values by RawPath. MTConnectDriverConfigDto declared no RawTags
property, so System.Text.Json silently dropped the array, and every reference
the server passed missed an index keyed by DataItem id: every value came back
BadNodeIdUnknown, with the whole suite green because every test handed the
driver its own Tags array directly.

- MTConnectDriverOptions gains RawTags; the config DTO binds it (verbatim — a
  bad blob is skipped per-tag at binding time, never fails the deployment).
- A new TagBinding, derived from the options and swapped with them in one write,
  carries RawPath -> dataItemId, its one-to-many reverse, and the definition set
  the observation index coerces against. Subscribe/Read resolve through it; the
  fan-out expands each touched dataItemId into every RawPath bound to it and
  publishes under THAT reference — the line the blocker turned on.
- Coercion type precedence: the blob's driverDataType/dataType (both spellings —
  the AdminUI's MTConnectTagConfigModel writes the latter), else a Tags entry for
  the same DataItem, else the Agent's own /probe declaration via
  MTConnectDataTypeInference (what makes a browse-committed {"address": id} blob
  publish as a number), else String. A present-but-invalid type rejects that tag.
- An unmapped reference falls through unchanged, so the dataItemId-keyed CLI
  surface and every existing test keep working, and an unclaimed RawPath reports
  Bad rather than throwing.

15 new tests build their config through the real DriverDeviceConfigMerger; all
four mutations (publish the dataItemId, drop the RawTags binding, drop the
derived coercion type, drop the reverse-map expansion) are caught.
This commit is contained in:
Joseph Doherty
2026-07-24 18:01:18 -04:00
parent b0046d6959
commit 51c8c7f30b
3 changed files with 893 additions and 11 deletions
@@ -0,0 +1,458 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// The v3 wire contract: the runtime hands this driver its authored tags as a <c>RawTags</c>
/// array of <see cref="RawTagEntry"/> (RawPath identity + driver <c>TagConfig</c> blob) and then
/// reads, subscribes, and routes published values <b>by RawPath</b> — never by the driver's own
/// internal address (here, the MTConnect DataItem <c>id</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>Every config in this file is built by the real
/// <see cref="DriverDeviceConfigMerger"/></b> — the same merge
/// <c>DeploymentArtifact.TryReadSpec</c> runs to produce a
/// <c>DriverInstanceSpec.DriverConfig</c>. A hand-written JSON literal would only prove the
/// driver binds the shape the test author imagined; this proves it binds the shape the
/// deploy artifact actually produces, including the Pascal-case property names
/// <c>RawTagEntry</c> serialises under and the <c>TagConfig</c> blob arriving as an escaped
/// string rather than a nested object.
/// </para>
/// <para>
/// <b>The load-bearing assertion is the reference on the event, not the value.</b>
/// <c>DriverHostActor</c> routes a published value by <c>(DriverInstanceId, RawPath)</c>
/// against a NodeId table it built from the same RawPaths; a driver that published a
/// perfectly correct value under its own dataItemId would miss that table on every tick and
/// the value would be dropped silently — with no error anywhere and every unit test green.
/// </para>
/// </remarks>
public sealed class MTConnectRawTagBindingTests
{
private const string AgentUri = "http://fixture-agent:5000";
/// <summary>The DataItem ids the canned <c>Fixtures/</c> documents report.</summary>
private const string PositionDataItemId = "dev1_pos";
private const string ExecutionDataItemId = "dev1_execution";
private const string PartCountDataItemId = "dev1_partcount";
/// <summary>The RawPaths a deployed <c>/raw</c> tree would give those data items.</summary>
private const string PositionRawPath = "Plant/MTConnect/dev1/Position";
private const string ExecutionRawPath = "Plant/MTConnect/dev1/Execution";
private const string PartCountRawPath = "Plant/MTConnect/dev1/PartCount";
/// <summary>The <c>instanceId</c> every canned fixture shares.</summary>
private const long FixtureInstanceId = 1655000000L;
/// <summary>The cursor <c>Fixtures/current.xml</c> primes the pump with.</summary>
private const long PrimedNextSequence = 108L;
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
private const uint BadNodeIdUnknown = 0x80340000u;
private const uint BadTypeMismatch = 0x80740000u;
private static readonly DateTime ObservedAt = new(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc);
private static CancellationToken Ct => TestContext.Current.CancellationToken;
/// <summary>
/// The constructor options a spawned driver starts from: the endpoint only. Everything the
/// data plane binds arrives in the merged config document, exactly as it does in production.
/// </summary>
private static MTConnectDriverOptions Bare() => new() { AgentUri = AgentUri };
/// <summary>
/// Builds the merged <c>DriverConfig</c> for one MTConnect driver instance the way the deploy
/// artifact does: driver-level config + the device's <c>DeviceConfig</c> + the authored raw
/// tags injected as the <c>RawTags</c> array.
/// </summary>
private static string MergedConfig(params RawTagEntry[] rawTags) =>
DriverDeviceConfigMerger.Merge(
$$"""{"AgentUri":"{{AgentUri}}"}""",
[new DriverDeviceConfigMerger.DeviceRow("dev1", "{}")],
rawTags);
/// <summary>One authored raw tag: a RawPath bound to a DataItem id, typed for coercion.</summary>
private static RawTagEntry Entry(string rawPath, string dataItemId, string? driverDataType = null) =>
new(
rawPath,
driverDataType is null
? $$"""{"fullName":"{{dataItemId}}"}"""
: $$"""{"fullName":"{{dataItemId}}","driverDataType":"{{driverDataType}}"}""",
WriteIdempotent: false,
DeviceName: "dev1");
private static MTConnectStreamsResult Chunk(
long firstSequence, long nextSequence, params (string Id, string Value)[] observations) =>
new(
FixtureInstanceId,
nextSequence,
firstSequence,
[.. observations.Select(o => new MTConnectObservation(o.Id, o.Value, ObservedAt))]);
private static IReadOnlyList<DataChangeEventArgs> For(
ConcurrentQueue<DataChangeEventArgs> seen, string reference) =>
[.. seen.Where(e => e.FullReference == reference)];
private static ConcurrentQueue<DataChangeEventArgs> Record(MTConnectDriver driver)
{
var seen = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => seen.Enqueue(e);
return seen;
}
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> DeployedAsync(
params RawTagEntry[] rawTags)
{
var agent = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Bare(), "mt1", _ => agent);
await driver.InitializeAsync(MergedConfig(rawTags), Ct);
return (driver, agent);
}
// ---- the production data plane: subscribe by RawPath, publish by RawPath ----
/// <summary>
/// The whole blocker in one case: a deployed driver is subscribed by <b>RawPath</b> (that is
/// what <c>DriverInstanceActor.HandleSubscribeAsync</c> passes, and what
/// <c>DriverHostActor</c>'s NodeId table is keyed by) and must answer under that same
/// RawPath — both for the initial value and for every streamed chunk.
/// </summary>
[Fact]
public async Task Subscribing_by_raw_path_publishes_under_the_raw_path()
{
var (driver, agent) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);
// Initial data, out of the priming /current — under the RawPath, not the dataItemId.
var initial = For(seen, PositionRawPath).ShouldHaveSingleItem();
initial.Snapshot.StatusCode.ShouldBe(Good);
initial.Snapshot.Value.ShouldBe(123.4567d);
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
var streamed = For(seen, PositionRawPath);
streamed.Count.ShouldBe(2);
streamed[1].Snapshot.StatusCode.ShouldBe(Good);
streamed[1].Snapshot.Value.ShouldBe(201.5d);
// …and NOTHING is ever published under the driver-internal dataItemId: a value routed by that
// reference misses DriverHostActor's NodeId table and is dropped without a trace.
For(seen, PositionDataItemId).ShouldBeEmpty();
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// The coercion type must survive the new binding path. The <c>TagConfig</c> blob's
/// <c>driverDataType</c> is what tells the observation index to publish <c>201.5</c> as a
/// <see cref="double"/> rather than the Agent's raw text — losing it would turn every value
/// into a Good-coded string, which the OPC UA node (typed Double) cannot carry.
/// </summary>
[Fact]
public async Task Raw_tag_config_carries_the_coercion_type_into_the_index()
{
var (driver, _) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
Entry(ExecutionRawPath, ExecutionDataItemId, nameof(DriverDataType.String)));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath, ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct);
For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBeOfType<double>();
For(seen, ExecutionRawPath)[0].Snapshot.Value.ShouldBeOfType<string>();
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// <see cref="IReadable.ReadAsync"/> keys by RawPath too. The server never calls it (the data
/// plane is subscribe-only), but the driver CLI does, and a read that answered
/// <c>BadNodeIdUnknown</c> for a tag the subscription serves happily would be a lie about the
/// deployment.
/// </summary>
[Fact]
public async Task Read_resolves_raw_paths()
{
var (driver, _) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32)));
var values = await driver.ReadAsync([PositionRawPath, PartCountRawPath, "Plant/MTConnect/dev1/Nope"], Ct);
values.Count.ShouldBe(3);
values[0].StatusCode.ShouldBe(Good);
values[0].Value.ShouldBe(123.4567d);
// Authored + bound, but the Agent reports it UNAVAILABLE in the fixture.
values[1].StatusCode.ShouldBe(BadNoCommunication);
// Not in RawTags at all: a miss is a miss, surfaced as Bad — never a throw.
values[2].StatusCode.ShouldBe(BadNodeIdUnknown);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// The AdminUI's typed editor (<c>MTConnectTagConfigModel</c>) writes the coercion type under
/// <c>dataType</c>, not <c>driverDataType</c>. Reading only the driver's own spelling would
/// make every editor-authored tag fall back to String — the editor and the runtime disagreeing
/// about the type of every value, with nothing anywhere reporting it.
/// </summary>
[Fact]
public async Task The_admin_ui_datatype_spelling_reaches_the_coercion()
{
var (driver, _) = await DeployedAsync(
new RawTagEntry(
PositionRawPath,
"""{"fullName":"dev1_pos","dataType":"Float64","mtCategory":"SAMPLE","mtType":"POSITION"}""",
WriteIdempotent: false,
DeviceName: "dev1"));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);
For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// A browse-committed tag's blob carries ONLY the address (<c>RawBrowseCommitMapper</c> has no
/// MTConnect branch, so it writes the generic <c>{"address": id}</c> shape) — no type at all.
/// The driver falls back to the Agent's own <c>/probe</c> declaration, so a POSITION SAMPLE
/// publishes as a <see cref="double"/> rather than as the Agent's raw text under a
/// numerically-typed OPC UA node.
/// </summary>
[Fact]
public async Task A_typeless_blob_takes_the_type_the_agent_declares()
{
var (driver, _) = await DeployedAsync(
new RawTagEntry(PositionRawPath, """{"address":"dev1_pos"}""", false, "dev1"));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);
var initial = For(seen, PositionRawPath)[0];
initial.Snapshot.StatusCode.ShouldBe(Good);
initial.Snapshot.Value.ShouldBeOfType<double>().ShouldBe(123.4567d);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// A <see cref="MTConnectDriverOptions.Tags"/> entry supplies the coercion type for a raw tag
/// whose blob declares none — the middle rung of the documented precedence, and what keeps a
/// driver authored through both surfaces coherent.
/// </summary>
[Fact]
public async Task Tags_supply_the_type_for_a_raw_tag_whose_blob_declares_none()
{
var agent = CannedAgentClient.FromFixtures();
// dev1_execution is an EVENT the probe model would infer as String; the Tags entry overrides
// that with Reference (a type nothing could infer), so the assertion can only pass if the
// Tags entry — not the inference — supplied it.
var driver = new MTConnectDriver(
new MTConnectDriverOptions
{
AgentUri = AgentUri,
Tags = [new MTConnectTagDefinition(ExecutionDataItemId, DriverDataType.Int32)],
},
"mt1",
_ => agent);
// A config document with a body REPLACES the constructor options, so the Tags entry has to
// travel in it too — this is the shape a driver authored through both surfaces deploys as.
await driver.InitializeAsync(
$$"""
{"AgentUri":"{{AgentUri}}",
"Tags":[{"FullName":"{{ExecutionDataItemId}}","DriverDataType":"Int32"}],
"RawTags":[{"RawPath":"{{ExecutionRawPath}}","TagConfig":"{\"fullName\":\"{{ExecutionDataItemId}}\"}","WriteIdempotent":false,"DeviceName":"dev1"}]}
""",
Ct);
var seen = Record(driver);
await driver.SubscribeAsync([ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct);
// The fixture reports dev1_execution as "ACTIVE" — not an Int32, so the Int32 the Tags entry
// declared is what makes this a type mismatch instead of a Good string.
For(seen, ExecutionRawPath)[0].Snapshot.StatusCode.ShouldBe(BadTypeMismatch);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// <c>RawTags</c> and <c>Tags</c> may both name the same DataItem. The RawTags blob is the
/// deploy-time authority and its declared type wins; the dataItemId-keyed reference keeps
/// working alongside the RawPath, so a CLI session against a deployed driver still resolves.
/// </summary>
[Fact]
public async Task Both_collections_together_bind_both_references_with_rawtags_winning_the_type()
{
var agent = CannedAgentClient.FromFixtures();
var driver = new MTConnectDriver(Bare(), "mt1", _ => agent);
await driver.InitializeAsync(
$$"""
{"AgentUri":"{{AgentUri}}",
"Tags":[{"FullName":"{{PositionDataItemId}}","DriverDataType":"String"}],
"RawTags":[{"RawPath":"{{PositionRawPath}}","TagConfig":"{\"fullName\":\"{{PositionDataItemId}}\",\"driverDataType\":\"Float64\"}","WriteIdempotent":false,"DeviceName":"dev1"}]}
""",
Ct);
var seen = Record(driver);
await driver.SubscribeAsync(
[PositionRawPath, PositionDataItemId], TimeSpan.FromMilliseconds(50), Ct);
// The RawTags blob's Float64 beat the Tags entry's String — for BOTH references, because
// there is one index and one definition per DataItem.
For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d);
For(seen, PositionDataItemId)[0].Snapshot.Value.ShouldBe(123.4567d);
// …and a streamed chunk raises BOTH, once each.
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
For(seen, PositionRawPath).Count.ShouldBe(2);
For(seen, PositionDataItemId).Count.ShouldBe(2);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// One DataItem may back several authored raw tags (the same machine signal projected into two
/// places in the <c>/raw</c> tree). Each is its own OPC UA node and each must receive the
/// value — a one-to-one map would silently serve only whichever tag was authored last.
/// </summary>
[Fact]
public async Task One_data_item_fans_out_to_every_raw_path_bound_to_it()
{
const string secondRawPath = "Plant/MTConnect/dev1/Mirror/Position";
var (driver, agent) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
Entry(secondRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
var seen = Record(driver);
await driver.SubscribeAsync(
[PositionRawPath, secondRawPath], TimeSpan.FromMilliseconds(50), Ct);
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
For(seen, PositionRawPath).Count.ShouldBe(2);
For(seen, secondRawPath).Count.ShouldBe(2);
For(seen, secondRawPath)[1].Snapshot.Value.ShouldBe(201.5d);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// A blob that names no DataItem id, or declares a <c>dataType</c> that is not a
/// <see cref="DriverDataType"/> (including the numerically-serialized-enum trap, where
/// <c>Enum.TryParse</c> would happily take <c>"8"</c> as the member with that ordinal), skips
/// that ONE tag — the rest of the driver deploys and serves normally.
/// </summary>
[Theory]
[InlineData("""{"units":"MILLIMETER"}""")]
[InlineData("""{"fullName":"dev1_pos","dataType":"Flot64"}""")]
[InlineData("""{"fullName":"dev1_pos","dataType":"8"}""")]
[InlineData("not json at all")]
public async Task An_unusable_blob_skips_only_its_own_tag(string badBlob)
{
var (driver, _) = await DeployedAsync(
new RawTagEntry("Plant/MTConnect/dev1/Bad", badBlob, false, "dev1"),
Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32)));
var values = await driver.ReadAsync(["Plant/MTConnect/dev1/Bad", PartCountRawPath], Ct);
values[0].StatusCode.ShouldBe(BadNodeIdUnknown);
values[1].StatusCode.ShouldBe(BadNoCommunication); // bound; the Agent reports it UNAVAILABLE
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// A redeploy that re-homes a tag in the <c>/raw</c> tree changes its RawPath. The driver must
/// rebind to the new one — and stop answering for the old one, which no longer names anything.
/// This is also the ordering pin: the binding is installed with the options, before the index
/// the pump republishes from, so a standing subscription can never be served through one
/// document's RawPaths against another's values.
/// </summary>
[Fact]
public async Task Reinitialize_rebinds_raw_paths()
{
const string movedRawPath = "Plant/MTConnect/dev1/Spindle/Position";
var (driver, agent) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
var seen = Record(driver);
await driver.SubscribeAsync([PositionRawPath, movedRawPath], TimeSpan.FromMilliseconds(50), Ct);
For(seen, movedRawPath)[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown);
await driver.ReinitializeAsync(
MergedConfig(Entry(movedRawPath, PositionDataItemId, nameof(DriverDataType.Float64))), Ct);
// The restarted pump republishes every standing reference as its first act.
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
For(seen, movedRawPath)[^1].Snapshot.Value.ShouldBe(201.5d);
For(seen, PositionRawPath)[^1].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown);
await driver.ShutdownAsync(Ct);
}
/// <summary>
/// The factory is the production construction seam (<c>DriverFactoryRegistry</c> calls it with
/// the merged artifact config), so <c>RawTags</c> has to survive that path too — a driver whose
/// RawPaths only bind when constructed by hand is a driver that works in tests and nowhere else.
/// </summary>
[Fact]
public void The_factory_binds_raw_tags_from_the_merged_config()
{
var driver = MTConnectDriverFactoryExtensions.CreateInstance(
"mt1",
MergedConfig(Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64))));
var bound = driver.EffectiveOptions.RawTags.ShouldHaveSingleItem();
bound.RawPath.ShouldBe(PositionRawPath);
bound.DeviceName.ShouldBe("dev1");
bound.TagConfig.ShouldContain(PositionDataItemId);
}
/// <summary>
/// A subscribed RawPath with no <c>RawTags</c> entry behind it (an authoring slip, or a tag
/// whose blob would not map) must report Bad and keep serving every other reference — the
/// documented <see cref="EquipmentTagRefResolver{TDef}"/> posture: "a miss is a miss".
/// </summary>
[Fact]
public async Task An_unmapped_raw_path_reports_bad_and_never_throws()
{
var (driver, agent) = await DeployedAsync(
Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
var seen = Record(driver);
await driver.SubscribeAsync(
[PositionRawPath, "Plant/MTConnect/dev1/Ghost"], TimeSpan.FromMilliseconds(50), Ct);
For(seen, "Plant/MTConnect/dev1/Ghost")[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown);
// The bound reference is unaffected — the miss did not poison the batch or the stream.
await agent.PumpAsync(
Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
For(seen, PositionRawPath).Count.ShouldBe(2);
await driver.ShutdownAsync(Ct);
}
}