using System.Data;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
///
/// Live-gate 8 F1: the per-sample aggregate row mapper () over a
/// hand-built row in the column layout. A row whose counts include
/// samples but whose first/last-of-any-quality picks are NULL used to throw InvalidCastException: Column 'ts' is
/// null and fail the node's whole read; it now maps to absent picks the evaluator answers as BadNoData.
///
public sealed class BucketRowMappingTests
{
private const uint NoDataCalc = 0x809B0401u;
private static readonly DateTime T = new(2026, 10, 11, 12, 0, 0, DateTimeKind.Utc);
/// The StartEnd layout: b + 5 counts, two usable picks (kind, num, int, str, ts), two any picks (ts, kind, num, int, str, status).
private static DataTable StartEndTable()
{
var t = new DataTable();
t.Columns.Add("b", typeof(long));
foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(long));
for (var p = 0; p < 2; p++)
{
t.Columns.Add($"k{p}", typeof(short));
t.Columns.Add($"n{p}", typeof(double));
t.Columns.Add($"i{p}", typeof(long));
t.Columns.Add($"s{p}", typeof(string));
t.Columns.Add($"t{p}", typeof(DateTime));
}
for (var p = 0; p < 2; p++)
{
t.Columns.Add($"at{p}", typeof(DateTime));
t.Columns.Add($"ak{p}", typeof(short));
t.Columns.Add($"an{p}", typeof(double));
t.Columns.Add($"ai{p}", typeof(long));
t.Columns.Add($"as{p}", typeof(string));
t.Columns.Add($"st{p}", typeof(long));
}
return t;
}
private static DataRow Counts(DataTable t, long bucket, long total)
{
var row = t.NewRow();
row[0] = bucket;
row[1] = total; row[2] = total; row[3] = total; row[4] = 0L; row[5] = 0L;
return row;
}
private static BucketRow Map(DataTable t, AggregateFamily family)
{
using var r = t.CreateDataReader();
r.Read().ShouldBeTrue();
return QuestDbHistorianDataSource.ReadBucket(r, family);
}
[Fact]
public void A_row_with_counts_but_null_first_and_last_of_any_quality_maps_to_absent_picks_without_throwing()
{
var t = StartEndTable();
var row = Counts(t, 3, 2);
// A non-matching BYTE pick reads back as kind 0 (BYTE has no NULL) with every other column NULL.
row["k0"] = (short)0;
row["k1"] = (short)0;
t.Rows.Add(row);
var b = Map(t, AggregateFamily.StartEnd);
b.Bucket.ShouldBe(3);
b.Total.ShouldBe(2);
b.FirstAny.ShouldBeNull();
b.LastAny.ShouldBeNull();
b.First.Kind.ShouldBe(HistorianValueKind.Empty);
b.Last.Kind.ShouldBe(HistorianValueKind.Empty);
foreach (var agg in new[] { HistoryAggregateType.Start, HistoryAggregateType.End, HistoryAggregateType.Delta })
AggregateEvaluator.Evaluate(agg, b, T, T).StatusCode.ShouldBe(NoDataCalc);
}
[Fact]
public void A_consistent_row_still_maps_every_pick()
{
var t = StartEndTable();
var row = Counts(t, 0, 2);
row["k0"] = (short)1; row["n0"] = 10d; row["t0"] = T;
row["k1"] = (short)1; row["n1"] = 20d; row["t1"] = T.AddSeconds(1);
row["at0"] = T; row["ak0"] = (short)1; row["an0"] = 10d; row["st0"] = 0L;
row["at1"] = T.AddSeconds(1); row["ak1"] = (short)1; row["an1"] = 20d; row["st1"] = 0L;
t.Rows.Add(row);
var b = Map(t, AggregateFamily.StartEnd);
b.First.ShouldBe(HistorianValue.FromDouble(10));
b.Last.ShouldBe(HistorianValue.FromDouble(20));
b.FirstAny.ShouldNotBeNull().TimeUtc.ShouldBe(T);
b.LastAny.ShouldNotBeNull().Value.ShouldBe(HistorianValue.FromDouble(20));
AggregateEvaluator.Evaluate(HistoryAggregateType.Delta, b, T, T).Value.ShouldBe(10d);
}
[Fact]
public void An_actual_time_row_with_counts_but_no_extreme_is_no_data()
{
var t = new DataTable();
t.Columns.Add("b", typeof(long));
foreach (var c in new[] { "total", "usable", "good", "bad", "nonnum" }) t.Columns.Add(c, typeof(long));
t.Columns.Add("num_value", typeof(double));
t.Columns.Add("ts", typeof(DateTime));
t.Rows.Add(0L, 1L, 1L, 1L, 0L, 0L, DBNull.Value, DBNull.Value);
var b = Map(t, AggregateFamily.ActualTime);
b.Extreme.ShouldBeNull();
AggregateEvaluator.Evaluate(HistoryAggregateType.MaximumActualTime, b, T, T).StatusCode.ShouldBe(NoDataCalc);
}
}