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;
///
/// Unit tests for — the Template/DataSet/PropertySet mapping
/// (Gitea #515).
///
///
/// Two properties dominate this file. First, what is flattened is addressable and correctly
/// typed. Second — and the one that would be easy to lose in a later edit —
/// skip-and-warn survives: anything that cannot be flattened honestly stays
/// 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.
///
[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 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 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 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;
}
}