using System.Globalization; using System.Text.Json; using Opc.Ua; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.OpcUaServer; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Compatibility.Aggregates; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// Compares all preserved aggregate factories with independently generated SDK 1.5.378.182 vectors. public sealed class Sdk2AggregateCompatibilityTests { [Fact] public void All_37_factories_preserve_the_4144_baseline_vectors() { var baseline = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; var telemetry = DefaultTelemetry.Create(_ => { }); var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc); var failures = new List(); foreach (var vector in baseline.Vectors) { var id = (NodeId)typeof(ObjectIds).GetProperty(vector.Aggregate)!.GetValue(null)!; var start = epoch.AddSeconds(vector.Reverse ? 70 : 0); var end = epoch.AddSeconds(vector.Reverse ? 0 : 70); var config = new AggregateConfiguration { TreatUncertainAsBad = vector.UncertainAsBad, PercentDataBad = 100, PercentDataGood = 100, UseSlopedExtrapolation = false, UseServerCapabilitiesDefaults = false }; var calculator = CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry); var output = new List(); string? error = null; try { var samples = baseline.Data[vector.DataSet].OrderBy(x => x.Seconds); foreach (var sample in vector.Reverse ? samples.Reverse() : samples) { var time = epoch.AddSeconds(sample.Seconds); calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time)); if (vector.Incremental) Drain(false); } Drain(true); } catch (Exception ex) { error = ex.GetType().FullName; } if (error != vector.Exception || !output.SequenceEqual(vector.Output)) { failures.Add($"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(vector.Output)} actual {JsonSerializer.Serialize(output)}"); } void Drain(bool partial) { for (var guard = 0; guard < 100; guard++) { if (!calculator.TryGetProcessedValue(partial, out var value)) return; var payload = SdkValueBoundary.ToClr(value.WrappedValue); output.Add(new(partial, payload?.GetType().Name, Format(payload), value.StatusCode.Code, ((DateTime)value.SourceTimestamp).Ticks, ((DateTime)value.ServerTimestamp).Ticks, value.SourcePicoseconds, value.ServerPicoseconds)); } throw new InvalidOperationException("Baseline calculator did not terminate."); } } baseline.Vectors.Length.ShouldBe(4144); baseline.Vectors.Select(x => x.Aggregate).Distinct().Count().ShouldBe(37); failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing."); } static object? Payload(Sample sample) => sample.Type switch { "Double" => double.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int64" => long.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Boolean" => bool.Parse(sample.Payload!), _ => null }; static string? Format(object? value) => value switch { null => null, double number => number.ToString("R", CultureInfo.InvariantCulture), float number => number.ToString("R", CultureInfo.InvariantCulture), IFormattable f => f.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() }; record Baseline(long EpochTicks, int IntervalMilliseconds, Dictionary Data, Vector[] Vectors); record Sample(double Seconds,string Type,string? Payload,uint Status); record Value(bool PartialRequested,string? Type,string? Payload,uint Status,long SourceTicks,long ServerTicks,ushort SourcePicoseconds,ushort ServerPicoseconds); record Vector(string Aggregate,string DataSet,bool Reverse,bool Stepped,bool UncertainAsBad,bool Incremental,Value[] Output,string? Exception); }