Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/SparkplugFlattenerTests.cs
T
Joseph Doherty c74ccc55b5 feat(mqtt): flatten Sparkplug Template/DataSet metrics to sibling tags (#515)
`SparkplugCodec` handled scalars; `DataSet`, `Template` and `PropertySet` were skipped
with a warning. They now flatten to addressable tags (owner decision, recorded on the
issue):

  Template  → sibling metrics, `/`-joined, recursively: Motor → Motor/Speed, Motor/Stator/Temp
  DataSet   → one metric per cell, {column}/{rowIndex}: Recipe → Recipe/Step/0, Recipe/Target/1
  PropertySet → node metadata, never tags — it describes a metric rather than being one

Flattening was chosen over OPC UA Structures because flattened scalars are consumable by
any client with no type dictionary, and the result mirrors how the `/raw` tree works.

**The row index is always present, including on row 0.** Emitting a bare column name for
a single-row table and an indexed one otherwise would make a tag's name depend on how many
rows the plant published — a table growing from one row to two would rename every tag
already authored against it.

**A DATA metric carries the parent's alias and no name**, so member names cannot be
composed until the alias table has resolved the parent. Expansion therefore happens in
`SparkplugIngestor.PublishStructuredMembers` — after resolution, at the single funnel both
the DATA and birth-value paths already pass through — not at decode time. The alias table
installs a binding per member so browse enumerates them and a DDATA member that omits its
datatype falls back to the birth's (guessing from the CLR type would read a signed Int32 of
-42 out of its unsigned field as 4294967254).

**Skip-and-warn survives, and refusal is whole** — the explicit requirement on the issue.
A template definition, an unnamed member, an extension value, a column/type mismatch, a
short row, or a payload past MaxDepth(5)/MaxMembers(512) refuses the ENTIRE metric. A
silently half-flattened UDT looks exactly like a complete one in the picker.

The browse picker offers the members and deliberately NOT the structured parent, which has
no value and no servable datatype. The rediscovery signature likewise tracks members rather
than the parent, or a UDT gaining a member would leave the signature unmoved.

Member paths contain `/`, which #509 already solved: the slashed name travels intact as
`metricName` while the derived tag Name is coerced to a legal RawPath segment.

Live-gated 3/3 against the Mosquitto fixture with the project-owned simulator extended to
publish a nested Template and a 2x2 DataSet. Falsified at three points: dropping the member
bindings, not expanding the parent, and refusing to flatten templates each turn exactly the
expected tests red.

Also fixes a regression from the #511 commit in the same branch: `AddOtOpcUaDriverFactories`
resolved `IDriverPrimaryRoleSource` with `GetRequiredService` but only `AddOtOpcUaRuntime`
registered it, so a graph wiring driver factories without the full runtime failed at
resolution. Two Host integration suites do exactly that and caught it; both extensions now
TryAdd the same concrete `RedundancyRoleView`, so the registration is order-independent and
the fail-loud resolve is kept.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-07-31 08:14:04 -04:00

295 lines
13 KiB
C#

using Google.Protobuf;
using Org.Eclipse.Tahu.Protobuf;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Sparkplug;
using TahuDataType = Org.Eclipse.Tahu.Protobuf.DataType;
namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <summary>
/// Unit tests for <see cref="SparkplugFlattener"/> — the Template/DataSet/PropertySet mapping
/// (Gitea #515).
/// </summary>
/// <remarks>
/// Two properties dominate this file. First, <b>what is flattened is addressable and correctly
/// typed</b>. Second — and the one that would be easy to lose in a later edit —
/// <b>skip-and-warn survives</b>: anything that cannot be flattened honestly stays
/// <see cref="SparkplugValueKind.Unsupported"/> rather than becoming a guessed scalar, because a
/// tag bound to data that is not what its name says is worse than a tag that never binds.
/// </remarks>
[Trait("Category", "Unit")]
public sealed class SparkplugFlattenerTests
{
// ---- Template -------------------------------------------------------------------------------
[Fact]
public void A_nested_template_flattens_to_slash_joined_paths()
{
// `/` is the separator Sparkplug itself uses for metric-name hierarchy, so `Motor/Stator/Temp`
// is exactly what an edge node would have called a flat metric of the same meaning.
var stator = Template(
Member("Temp", TahuDataType.Double, m => m.DoubleValue = 71.5),
Member("Current", TahuDataType.Float, m => m.FloatValue = 3.25f));
var motor = Template(
Member("Speed", TahuDataType.Double, m => m.DoubleValue = 1500),
MemberTemplate("Stator", stator));
SparkplugFlattener.TryFlattenTemplate(motor, out var members).ShouldBeTrue();
members.Select(m => m.Path).ShouldBe(["Speed", "Stator/Temp", "Stator/Current"]);
members[1].DataType.ShouldBe(TahuDataType.Double);
members[2].Value.ShouldBe(3.25f);
}
[Fact]
public void A_template_member_with_no_name_refuses_the_whole_metric()
{
// A template member is addressed by name; there is no alias fallback inside a template. Emitting
// the members that DO have names would publish a UDT silently missing part of itself, which
// reads in the picker exactly like a complete one.
var template = Template(
Member("Speed", TahuDataType.Double, m => m.DoubleValue = 1),
new Payload.Types.Metric { Datatype = (uint)TahuDataType.Double, DoubleValue = 2 });
SparkplugFlattener.TryFlattenTemplate(template, out var members).ShouldBeFalse();
members.ShouldBeEmpty();
}
[Fact]
public void An_extension_value_inside_a_template_refuses_the_whole_metric()
{
// An extension value is a third-party escape hatch with no defined semantics. There is nothing
// honest to flatten it into, and the sibling members cannot be trusted to be the whole story.
var template = Template(
Member("Speed", TahuDataType.Double, m => m.DoubleValue = 1),
Member("Odd", TahuDataType.Unknown, m => m.ExtensionValue = new Payload.Types.Metric.Types.MetricValueExtension()));
SparkplugFlattener.TryFlattenTemplate(template, out _).ShouldBeFalse();
}
[Fact]
public void A_template_definition_is_refused()
{
// A definition carries the UDT's shape with no instance values — metadata for consumers, not
// signal. Flattening it would materialise tags that can never read.
var definition = Template(Member("Speed", TahuDataType.Double, _ => { }));
definition.IsDefinition = true;
SparkplugFlattener.TryFlattenTemplate(definition, out _).ShouldBeFalse();
}
[Fact]
public void Nesting_past_the_depth_bound_refuses_the_whole_metric_rather_than_truncating()
{
// This walks an object graph anyone on the broker can publish, so the recursion must be bounded.
// Refusing whole beats truncating: a silently half-flattened UDT looks exactly like a complete
// one, and the operator would author tags against a subtree that quietly stops partway down.
var deepest = Template(Member("Leaf", TahuDataType.Double, m => m.DoubleValue = 1));
var current = deepest;
for (var i = 0; i < SparkplugFlattener.MaxDepth + 1; i++)
{
current = Template(MemberTemplate($"L{i}", current));
}
SparkplugFlattener.TryFlattenTemplate(current, out var members).ShouldBeFalse();
members.ShouldBeEmpty();
// Falsifying control: the same shape one level inside the bound flattens fine, so the refusal
// above is the bound doing its job rather than nesting being broken outright.
var shallow = Template(MemberTemplate("L0", deepest));
SparkplugFlattener.TryFlattenTemplate(shallow, out var ok).ShouldBeTrue();
ok.Single().Path.ShouldBe("L0/Leaf");
}
// ---- DataSet ---------------------------------------------------------------------------------
[Fact]
public void A_dataset_flattens_to_one_member_per_cell_with_the_row_index_always_present()
{
// Uniform naming, deliberately. Emitting a bare `{column}` for a single row and
// `{column}/{row}` otherwise would make a tag's own name depend on how many rows the plant
// happened to publish, so a table growing from one row to two would rename every existing tag
// out from under its authored binding.
var dataSet = DataSet(
columns: ["Temp", "Pressure"],
types: [TahuDataType.Double, TahuDataType.Int32],
rows:
[
[Cell(v => v.DoubleValue = 20.5), Cell(v => v.IntValue = 3)],
[Cell(v => v.DoubleValue = 21.0), Cell(v => v.IntValue = 4)],
]);
SparkplugFlattener.TryFlattenDataSet(dataSet, out var members).ShouldBeTrue();
members.Select(m => m.Path).ShouldBe(["Temp/0", "Pressure/0", "Temp/1", "Pressure/1"]);
members[0].DataType.ShouldBe(TahuDataType.Double);
members[1].DataType.ShouldBe(TahuDataType.Int32);
members[3].Value.ShouldBe(4U);
}
[Fact]
public void A_single_row_dataset_still_carries_the_row_index()
=> Flatten(DataSet(["Temp"], [TahuDataType.Double], [[Cell(v => v.DoubleValue = 1)]]))
.Single().Path.ShouldBe("Temp/0");
[Fact]
public void An_empty_cell_is_a_null_member_not_a_zero()
{
// A DataSetValue has no is_null field, so an empty oneof IS the null cell. Reading it as the
// proto default would publish 0 for a cell the plant left blank — plausible, Good, and wrong.
var members = Flatten(DataSet(["Temp"], [TahuDataType.Double], [[new Payload.Types.DataSet.Types.DataSetValue()]]));
members.Single().ValueKind.ShouldBe(SparkplugValueKind.Null);
members.Single().Value.ShouldBeNull();
}
[Fact]
public void A_column_and_type_count_mismatch_refuses_the_dataset()
// Columns and types are parallel arrays. Guessing which to trust would type a column by its
// neighbour, and every cell in it would publish at Good quality as the wrong type.
=> SparkplugFlattener.TryFlattenDataSet(
DataSet(["Temp", "Pressure"], [TahuDataType.Double], [] ), out _)
.ShouldBeFalse();
[Fact]
public void A_short_row_refuses_the_dataset()
// Not a row of nulls: emitting the cells that ARE present publishes a table silently missing
// its right-hand columns.
=> SparkplugFlattener.TryFlattenDataSet(
DataSet(["Temp", "Pressure"], [TahuDataType.Double, TahuDataType.Int32],
[[Cell(v => v.DoubleValue = 1)]]),
out _)
.ShouldBeFalse();
[Fact]
public void An_unnamed_column_refuses_the_dataset()
=> SparkplugFlattener.TryFlattenDataSet(
DataSet(["", "Pressure"], [TahuDataType.Double, TahuDataType.Int32],
[[Cell(v => v.DoubleValue = 1), Cell(v => v.IntValue = 2)]]),
out _)
.ShouldBeFalse();
[Fact]
public void A_dataset_past_the_member_bound_refuses_rather_than_truncating()
{
var columns = Enumerable.Range(0, 8).Select(i => $"c{i}").ToArray();
var types = Enumerable.Repeat(TahuDataType.Int32, 8).ToArray();
var rows = Enumerable.Range(0, 100)
.Select(_ => Enumerable.Range(0, 8).Select(_ => Cell(v => v.IntValue = 1)).ToArray())
.ToArray();
// 800 cells, past MaxMembers (512).
SparkplugFlattener.TryFlattenDataSet(DataSet(columns, types, rows), out var members).ShouldBeFalse();
members.ShouldBeEmpty();
}
// ---- PropertySet ------------------------------------------------------------------------------
[Fact]
public void Properties_project_to_flat_metadata_including_one_nested_level()
{
var nested = new Payload.Types.PropertySet();
nested.Keys.Add("Low");
nested.Values.Add(new Payload.Types.PropertyValue { DoubleValue = 0 });
var set = new Payload.Types.PropertySet();
set.Keys.Add("engUnit");
set.Values.Add(new Payload.Types.PropertyValue { StringValue = "degC" });
set.Keys.Add("Range");
set.Values.Add(new Payload.Types.PropertyValue { PropertysetValue = nested });
var projected = SparkplugFlattener.ProjectProperties(set);
projected.Select(p => p.Name).ShouldBe(["engUnit", "Range/Low"]);
projected[0].Value.ShouldBe("degC");
}
[Fact]
public void Properties_never_become_members()
{
// The recorded decision, and the honest reading of the spec: a PropertySet describes a metric
// (units, ranges, quality hints) — it is not a value the metric HAS. Surfacing it as sibling
// tags would invent plant signals out of documentation.
var set = new Payload.Types.PropertySet();
set.Keys.Add("engUnit");
set.Values.Add(new Payload.Types.PropertyValue { StringValue = "degC" });
var payload = new Payload();
var metric = new Payload.Types.Metric
{
Name = "Temperature", Datatype = (uint)TahuDataType.Double, DoubleValue = 20.5, Properties = set,
};
payload.Metrics.Add(metric);
var decoded = SparkplugCodec.Decode(payload.ToByteArray()).Metrics.Single();
decoded.ValueKind.ShouldBe(SparkplugValueKind.Scalar);
decoded.MemberList.ShouldBeEmpty();
decoded.PropertyList.Single().Name.ShouldBe("engUnit");
}
[Fact]
public void A_malformed_property_set_projects_to_nothing_rather_than_throwing()
{
var set = new Payload.Types.PropertySet();
set.Keys.Add("orphan"); // no matching value
SparkplugFlattener.ProjectProperties(set).ShouldBeEmpty();
SparkplugFlattener.ProjectProperties(null).ShouldBeEmpty();
}
// ---- helpers ----------------------------------------------------------------------------------
private static IReadOnlyList<SparkplugMember> Flatten(Payload.Types.DataSet dataSet)
{
SparkplugFlattener.TryFlattenDataSet(dataSet, out var members).ShouldBeTrue();
return members;
}
private static Payload.Types.Template Template(params Payload.Types.Metric[] members)
{
var template = new Payload.Types.Template { Version = "1", IsDefinition = false };
template.Metrics.AddRange(members);
return template;
}
private static Payload.Types.Metric Member(
string name, TahuDataType dataType, Action<Payload.Types.Metric> setValue)
{
var metric = new Payload.Types.Metric { Name = name, Datatype = (uint)dataType };
setValue(metric);
return metric;
}
private static Payload.Types.Metric MemberTemplate(string name, Payload.Types.Template value) =>
new() { Name = name, Datatype = (uint)TahuDataType.Template, TemplateValue = value };
private static Payload.Types.DataSet.Types.DataSetValue Cell(
Action<Payload.Types.DataSet.Types.DataSetValue> setValue)
{
var cell = new Payload.Types.DataSet.Types.DataSetValue();
setValue(cell);
return cell;
}
private static Payload.Types.DataSet DataSet(
string[] columns,
TahuDataType[] types,
Payload.Types.DataSet.Types.DataSetValue[][] rows)
{
var dataSet = new Payload.Types.DataSet { NumOfColumns = (ulong)columns.Length };
dataSet.Columns.AddRange(columns);
dataSet.Types_.AddRange(types.Select(t => (uint)t));
foreach (var cells in rows)
{
var row = new Payload.Types.DataSet.Types.Row();
row.Elements.AddRange(cells);
dataSet.Rows.Add(row);
}
return dataSet;
}
}