Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataSetTests.cs
T

215 lines
8.2 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
/// <summary>
/// #650 — DATA_SET / TABLE observations are served as a String carrying compact, deterministic JSON.
/// Pins the parse (entries, cells, removed, resetTriggered) and the index's merge semantics
/// (/current replaces, /sample merges, UNAVAILABLE drops the accumulated set).
/// </summary>
public sealed class MTConnectDataSetTests
{
private const uint Good = 0x00000000u;
private const uint BadNoCommunication = 0x80310000u;
private static string Doc(string events, string attrs = "") =>
$"""
<MTConnectStreams>
<Header instanceId="1" nextSequence="2" firstSequence="1"/>
<Streams><DeviceStream><ComponentStream><Events>{events}</Events></ComponentStream></DeviceStream></Streams>
</MTConnectStreams>
""";
private static string DataSet(string body, string attrs = "") =>
$"""<VariableDataSet dataItemId="ds" timestamp="2026-07-24T12:00:00Z" count="0"{attrs}>{body}</VariableDataSet>""";
private static string Table(string body, string attrs = "") =>
$"""<WorkOffsetTable dataItemId="tb" timestamp="2026-07-24T12:00:00Z" count="0"{attrs}>{body}</WorkOffsetTable>""";
private static MTConnectObservationIndex NewIndex() =>
new([new MTConnectTagDefinition("ds", DriverDataType.String), new MTConnectTagDefinition("tb", DriverDataType.String)]);
private static void Apply(MTConnectObservationIndex idx, string events, bool baseline = false) =>
idx.Apply(MTConnectStreamsParser.Parse(Doc(events)), baseline);
[Fact]
public void Parser_captures_entries_cells_removed_and_reset()
{
var result = MTConnectStreamsParser.Parse(Doc(
DataSet("""<Entry key="a">1</Entry><Entry key="b" removed="true"/>""", " resetTriggered=\"MANUAL\"") +
Table("""<Entry key="G54"><Cell key="X">1.5</Cell><Cell key="Y">2</Cell></Entry>""")));
var ds = result.Observations[0];
ds.ResetTriggered.ShouldBeTrue();
ds.Entries!.Count.ShouldBe(2);
ds.Entries[0].ShouldBe(new MTConnectDataSetEntry("a", "1", false));
ds.Entries[1].Removed.ShouldBeTrue();
var tb = result.Observations[1];
tb.Entries!.ShouldHaveSingleItem().Cells!.Select(c => (c.Key, c.Value))
.ShouldBe([("X", "1.5"), ("Y", "2")]);
}
[Fact]
public void Data_set_is_served_as_sorted_compact_json_with_numbers_and_strings()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="toolNumber">7</Entry><Entry key="offsetX">1.25</Entry><Entry key="name">A &amp; "B"</Entry><Entry key="part">007</Entry>"""), baseline: true);
var snap = idx.Get("ds");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("""{"name":"A & \"B\"","offsetX":1.25,"part":"007","toolNumber":7}""");
}
[Fact]
public void Table_is_served_as_nested_json()
{
var idx = NewIndex();
Apply(idx, Table("""<Entry key="G55"><Cell key="X">3</Cell></Entry><Entry key="G54"><Cell key="Y">2</Cell><Cell key="X">1.0</Cell></Entry>"""), baseline: true);
idx.Get("tb").Value.ShouldBe("""{"G54":{"X":1.0,"Y":2},"G55":{"X":3}}""");
}
[Fact]
public void Sample_delta_merges_and_removed_entry_disappears()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry><Entry key="b">2</Entry>"""), baseline: true);
Apply(idx, DataSet("""<Entry key="b">3</Entry><Entry key="c">x</Entry>"""));
idx.Get("ds").Value.ShouldBe("""{"a":1,"b":3,"c":"x"}""");
Apply(idx, DataSet("""<Entry key="a" removed="true"/>"""));
idx.Get("ds").Value.ShouldBe("""{"b":3,"c":"x"}""");
}
[Fact]
public void Table_row_delta_replaces_the_row_and_removed_row_disappears()
{
var idx = NewIndex();
Apply(idx, Table("""<Entry key="G54"><Cell key="X">1</Cell><Cell key="Y">2</Cell></Entry><Entry key="G55"><Cell key="X">3</Cell></Entry>"""), baseline: true);
Apply(idx, Table("""<Entry key="G54"><Cell key="X">9</Cell></Entry><Entry key="G55" removed="true"/>"""));
idx.Get("tb").Value.ShouldBe("""{"G54":{"X":9}}""");
}
[Fact]
public void Current_baseline_replaces_rather_than_merges()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry><Entry key="b">2</Entry>"""), baseline: true);
Apply(idx, DataSet("""<Entry key="c">3</Entry>"""), baseline: true);
idx.Get("ds").Value.ShouldBe("""{"c":3}""");
}
[Fact]
public void ResetTriggered_replaces_the_accumulated_set_on_a_sample()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet("""<Entry key="z">9</Entry>""", " resetTriggered=\"MANUAL\""));
idx.Get("ds").Value.ShouldBe("""{"z":9}""");
}
[Fact]
public void ResetTriggered_with_no_entries_is_an_empty_set_not_an_absence()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet("", " resetTriggered=\"MANUAL\""));
var snap = idx.Get("ds");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("{}");
}
[Fact]
public void Unavailable_is_BadNoCommunication_and_drops_the_accumulated_set()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet("UNAVAILABLE"));
var snap = idx.Get("ds");
snap.StatusCode.ShouldBe(BadNoCommunication);
snap.Value.ShouldBeNull();
// A delta after the outage must not resurrect the pre-outage entry.
Apply(idx, DataSet("""<Entry key="b">2</Entry>"""));
idx.Get("ds").Value.ShouldBe("""{"b":2}""");
}
[Fact]
public void Several_observations_in_one_document_fold_in_order()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>""") + DataSet("""<Entry key="b">2</Entry>"""), baseline: true);
idx.Get("ds").Value.ShouldBe("""{"a":1,"b":2}""");
}
[Fact]
public void Clear_forgets_the_accumulated_set()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
idx.Clear();
Apply(idx, DataSet("""<Entry key="b">2</Entry>"""));
idx.Get("ds").Value.ShouldBe("""{"b":2}""");
}
[Fact]
public void Structured_content_that_is_not_entries_stays_BadNotSupported()
{
var idx = NewIndex();
Apply(idx, DataSet("<Other>1</Other>"), baseline: true);
idx.Get("ds").StatusCode.ShouldBe(0x803D0000u);
}
[Fact]
public void ResetTriggered_on_an_ordinary_sample_keeps_its_numeric_value()
{
// Regression: resetTriggered is also a standard attribute on accumulating SAMPLEs / counters. It must
// never turn one into an empty set.
var idx = new MTConnectObservationIndex(
[new MTConnectTagDefinition("pc", DriverDataType.Int64), new MTConnectTagDefinition("amt", DriverDataType.Float64)]);
idx.Apply(MTConnectStreamsParser.Parse(Doc(
"""<PartCount dataItemId="pc" timestamp="2026-07-24T12:00:00Z" resetTriggered="DAILY">5</PartCount>""" +
"""<Amount dataItemId="amt" timestamp="2026-07-24T12:00:00Z" resetTriggered="DAILY">2.5</Amount>""")));
idx.Get("pc").StatusCode.ShouldBe(Good);
idx.Get("pc").Value.ShouldBe(5L);
idx.Get("amt").Value.ShouldBe(2.5d);
}
[Fact]
public void An_empty_current_data_set_is_Good_empty_json_like_a_reset_one()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet(""), baseline: true);
var snap = idx.Get("ds");
snap.StatusCode.ShouldBe(Good);
snap.Value.ShouldBe("{}");
}
[Fact]
public void An_all_removed_set_is_the_same_empty_json()
{
var idx = NewIndex();
Apply(idx, DataSet("""<Entry key="a">1</Entry>"""), baseline: true);
Apply(idx, DataSet("""<Entry key="a" removed="true"/>"""));
idx.Get("ds").Value.ShouldBe("{}");
}
}