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. /// /// #809 (owner-approved 2026-11-11): PercentGood / DurationGood no longer count Uncertain time as Good. The baseline /// vectors that changes are exempted EXACTLY — a PercentGood / DurationGood vector whose output differs from the same /// case computed by the baseline SDK with TreatUncertainAsBad = true (its "twin": there 1.5.378.182 already /// counted Good regions only) — and each is pinned to that twin instead. The twin is baseline evidence, not output of /// the application's calculator, so the pin never regenerates expectations from the implementation. /// public sealed class Sdk2AggregateCompatibilityTests { private const string PercentGood = "AggregateFunction_PercentGood"; private const string DurationGood = "AggregateFunction_DurationGood"; private const int Issue809PercentGoodExemptions = 38; private const int Issue809DurationGoodExemptions = 26; [Theory] [InlineData(false)] [InlineData(true)] public async Task All_37_factories_preserve_the_6144_baseline_vectors(bool throughRuntimeManager) { await using var rig = throughRuntimeManager ? await ServerEventRig.StartAsync("AggregateBaseline", encrypted: false, TestContext.Current.CancellationToken) : null; var baseline = LoadBaseline(); var exemptions = Issue809Exemptions(baseline); var failures = new List(); foreach (var vector in baseline.Vectors) { // #809: an exempted case is held to its baseline uncertain-as-bad twin; every other case to its own vector. var expected = exemptions.TryGetValue(vector, out var twin) ? twin.Output : vector.Output; if (Check(baseline, vector, expected, rig) is { } failure) failures.Add(failure); } baseline.Vectors.Length.ShouldBe(6144); baseline.Vectors.Select(x => x.Aggregate).Distinct().Count().ShouldBe(37); exemptions.Keys.Count(x => x.Aggregate == PercentGood).ShouldBe(Issue809PercentGoodExemptions); exemptions.Keys.Count(x => x.Aggregate == DurationGood).ShouldBe(Issue809DurationGoodExemptions); failures.ShouldBeEmpty("The application must retain baseline values, CLR types, complete status bits, timestamps and incremental processing (#809 exemptions: the uncertain-as-bad twin)."); } /// /// #809 pin: the exemptions are exactly the PercentGood / DurationGood cases that counted Uncertain as Good, and /// every PercentGood / DurationGood case — exempt or not, whatever its configuration — now answers its baseline /// uncertain-as-bad twin (Good regions only). /// [Fact] public void Issue809_percent_and_duration_good_count_only_good_time() { var baseline = LoadBaseline(); var exemptions = Issue809Exemptions(baseline); var failures = new List(); var checkedCount = 0; var twinned = 0; foreach (var vector in baseline.Vectors.Where(v => v.Aggregate is PercentGood or DurationGood)) { // A data set with no uncertain-as-bad twin (the numeric-width edge sets) has no Uncertain region to move. var twin = UncertainAsBadTwin(baseline, vector); if (exemptions.ContainsKey(vector)) EffectiveTreatUncertainAsBad(vector).ShouldBeFalse($"{Describe(vector)}: only an Uncertain-as-Good case may change."); if (Check(baseline, vector, twin?.Output ?? vector.Output, rig: null) is { } failure) failures.Add(failure); checkedCount++; if (twin is not null) twinned++; } checkedCount.ShouldBe(352); twinned.ShouldBeGreaterThan(exemptions.Count); exemptions.Count.ShouldBe(Issue809PercentGoodExemptions + Issue809DurationGoodExemptions); failures.ShouldBeEmpty("PercentGood / DurationGood must count Good regions only (Part 13 §5.4.3.31/§5.4.3.33, #809)."); } /// /// #809 hand-computed pin, independent of the expected outputs in any fixture: the "uncertain" data set (0 s Good, /// 5 s Uncertain, 15 s Good, 25 s Bad, 35 s Good, 50 s Good) over 20 s intervals from 0 to 70 s, sloped, forward. /// [0,20): Good 0–5 and 15–20 = 10 s (the baseline said 20 s); [20,40): the bound at 20 slopes into the Bad sample /// (Uncertain), 25–35 Bad, 35–40 Good = 5 s (baseline 10 s); [40,60): 20 s; [60,70): all 10 s of the partial /// interval = 100 %. /// [Theory] [InlineData(DurationGood, new[] { 10000d, 5000d, 20000d, 10000d })] [InlineData(PercentGood, new[] { 50d, 25d, 100d, 100d })] public void Issue809_uncertain_time_is_not_good_hand_computed(string aggregate, double[] expected) { var baseline = LoadBaseline(); var vector = baseline.Vectors.Single(v => v.Aggregate == aggregate && v.DataSet == "uncertain" && !v.Reverse && !v.Stepped && !v.UncertainAsBad && !v.Incremental && v.Configuration is null); var output = Run(baseline, vector, rig: null, out var error); error.ShouldBeNull(); output.ShouldAllBe(x => x.Type == "Double"); output.Select(x => double.Parse(x.Payload!, CultureInfo.InvariantCulture)).ShouldBe(expected); } private static Baseline LoadBaseline() => JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Fixtures", "opcua-1.5.378.182-aggregates.json")), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; private static bool EffectiveTreatUncertainAsBad(Vector vector) => vector.Configuration?.TreatUncertainAsBad ?? vector.UncertainAsBad; /// The same case computed by the baseline SDK with TreatUncertainAsBad = true and no explicit profile. private static Vector? UncertainAsBadTwin(Baseline baseline, Vector vector) => baseline.Vectors.SingleOrDefault(v => v.Aggregate == vector.Aggregate && v.DataSet == vector.DataSet && v.Reverse == vector.Reverse && v.Stepped == vector.Stepped && v.Incremental == vector.Incremental && v.UncertainAsBad && v.Configuration is null); /// #809: the PercentGood / DurationGood vectors whose baseline output differs from their uncertain-as-bad twin. private static Dictionary Issue809Exemptions(Baseline baseline) => baseline.Vectors .Where(v => v.Aggregate is PercentGood or DurationGood) .Select(v => (Vector: v, Twin: UncertainAsBadTwin(baseline, v))) .Where(x => x.Twin is not null && !x.Vector.Output.SequenceEqual(x.Twin.Output)) .ToDictionary<(Vector Vector, Vector? Twin), Vector, Vector>(x => x.Vector, x => x.Twin!, System.Collections.Generic.ReferenceEqualityComparer.Instance); private static string Describe(Vector vector) => $"{vector.Aggregate}/{vector.DataSet}/rev={vector.Reverse}/step={vector.Stepped}/uncertainBad={vector.UncertainAsBad}/incremental={vector.Incremental}/config={JsonSerializer.Serialize(vector.Configuration)}"; private static string? Check(Baseline baseline, Vector vector, Value[] expected, ServerEventRig? rig) { var output = Run(baseline, vector, rig, out var error); return error != vector.Exception || !output.SequenceEqual(expected) || (vector.QueueAccepted is { } expectedAccepted && !output.Accepted.SequenceEqual(expectedAccepted)) ? $"{Describe(vector)}: error {vector.Exception} vs {error}; expected {JsonSerializer.Serialize(expected)} actual {JsonSerializer.Serialize(output.ToArray())}" : null; } private static RunOutput Run(Baseline baseline, Vector vector, ServerEventRig? rig, out string? error) { var telemetry = DefaultTelemetry.Create(_ => { }); var epoch = new DateTime(baseline.EpochTicks, DateTimeKind.Utc); 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 profile = vector.Configuration; var config = new AggregateConfiguration { TreatUncertainAsBad = profile?.TreatUncertainAsBad ?? vector.UncertainAsBad, PercentDataBad = profile?.PercentDataBad ?? 100, PercentDataGood = profile?.PercentDataGood ?? 100, UseSlopedExtrapolation = profile?.UseSlopedExtrapolation ?? false, UseServerCapabilitiesDefaults = profile?.UseServerCapabilitiesDefaults ?? false, }; var calculator = rig is not null ? rig.Server.CurrentInstance.AggregateManager.CreateCalculator(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config) : CompatibilityAggregateFactory.Create(id, start, end, baseline.IntervalMilliseconds, vector.Stepped, config, telemetry); calculator.ShouldNotBeNull($"Runtime registration must serve {vector.Aggregate}."); var output = new RunOutput(); 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); output.Accepted.Add(calculator.QueueRawValue(new DataValue(SdkValueBoundary.FromClr(Payload(sample)), sample.Status, time, time.AddTicks(sample.ServerOffsetTicks), sample.SourcePicoseconds, sample.ServerPicoseconds))); if (vector.Incremental) Drain(false); } Drain(true); } catch (Exception ex) { error = ex.GetType().FullName; } return 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, LegacyTimestampTicks(value.SourceTimestamp), LegacyTimestampTicks(value.ServerTimestamp), value.SourcePicoseconds, value.ServerPicoseconds)); } throw new InvalidOperationException("Baseline calculator did not terminate."); } } // UA FILETIME zero is the old CLR unset sentinel, not a different historical instant. private static long LegacyTimestampTicks(DateTimeUtc time) => time.IsNull ? DateTime.MinValue.Ticks : ((DateTime)time).Ticks; static object? Payload(Sample sample) => sample.Type switch { "SByte" => sbyte.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Byte" => byte.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int16" => short.Parse(sample.Payload!, CultureInfo.InvariantCulture), "UInt16" => ushort.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Int32" => int.Parse(sample.Payload!, CultureInfo.InvariantCulture), "UInt32" => uint.Parse(sample.Payload!, CultureInfo.InvariantCulture), "UInt64" => ulong.Parse(sample.Payload!, CultureInfo.InvariantCulture), "Single" => float.Parse(sample.Payload!, CultureInfo.InvariantCulture), "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() }; private sealed class RunOutput : List { public List Accepted { get; } = []; } record Baseline(long EpochTicks, int IntervalMilliseconds, Dictionary Data, Vector[] Vectors); record Sample(double Seconds,string Type,string? Payload,uint Status,ushort SourcePicoseconds = 0,ushort ServerPicoseconds = 0,long ServerOffsetTicks = 0); 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, Configuration? Configuration = null,bool[]? QueueAccepted = null); record Configuration(bool TreatUncertainAsBad,byte PercentDataBad,byte PercentDataGood,bool UseSlopedExtrapolation,bool UseServerCapabilitiesDefaults); }