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; } }