using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using MessagePack; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Galaxy; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.Historian; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Backend.MxAccess; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Sta; using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.Contracts; namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests; [Trait("Category", "Unit")] public sealed class HistoryReadAtTimeTests { private static MxAccessGalaxyBackend BuildBackend(IHistorianDataSource? historian, StaPump pump) => new( new GalaxyRepository(new GalaxyRepositoryOptions { ConnectionString = "Server=.;Database=ZB;Integrated Security=True;" }), new MxAccessClient(pump, new MxProxyAdapter(), "attime-test"), historian); [Fact] public async Task Returns_disabled_error_when_no_historian_configured() { using var pump = new StaPump("Test.Sta"); await pump.WaitForStartedAsync(); using var backend = BuildBackend(null, pump); var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest { TagReference = "T", TimestampsUtcUnixMs = new[] { 1L, 2L }, }, CancellationToken.None); resp.Success.ShouldBeFalse(); resp.Error.ShouldContain("Historian disabled"); } [Fact] public async Task Empty_timestamp_list_short_circuits_to_success_with_no_values() { using var pump = new StaPump("Test.Sta"); await pump.WaitForStartedAsync(); var fake = new FakeHistorian(); using var backend = BuildBackend(fake, pump); var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest { TagReference = "T", TimestampsUtcUnixMs = Array.Empty(), }, CancellationToken.None); resp.Success.ShouldBeTrue(); resp.Values.ShouldBeEmpty(); fake.Calls.ShouldBe(0); // no round-trip to SDK for empty timestamp list } [Fact] public async Task Timestamps_survive_Unix_ms_round_trip_to_DateTime() { using var pump = new StaPump("Test.Sta"); await pump.WaitForStartedAsync(); var t1 = new DateTime(2026, 4, 18, 10, 0, 0, DateTimeKind.Utc); var t2 = new DateTime(2026, 4, 18, 10, 5, 0, DateTimeKind.Utc); var fake = new FakeHistorian( new HistorianSample { Value = 100.0, Quality = 192, TimestampUtc = t1 }, new HistorianSample { Value = 101.5, Quality = 192, TimestampUtc = t2 }); using var backend = BuildBackend(fake, pump); var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest { TagReference = "TankLevel", TimestampsUtcUnixMs = new[] { new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds(), new DateTimeOffset(t2, TimeSpan.Zero).ToUnixTimeMilliseconds(), }, }, CancellationToken.None); resp.Success.ShouldBeTrue(); resp.Values.Length.ShouldBe(2); resp.Values[0].SourceTimestampUtcUnixMs.ShouldBe(new DateTimeOffset(t1, TimeSpan.Zero).ToUnixTimeMilliseconds()); resp.Values[0].StatusCode.ShouldBe(0u); // Good (quality 192) MessagePackSerializer.Deserialize(resp.Values[0].ValueBytes!).ShouldBe(100.0); fake.Calls.ShouldBe(1); fake.LastTimestamps.Length.ShouldBe(2); fake.LastTimestamps[0].ShouldBe(t1); fake.LastTimestamps[1].ShouldBe(t2); } [Fact] public async Task Missing_sample_maps_to_Bad_category() { using var pump = new StaPump("Test.Sta"); await pump.WaitForStartedAsync(); // Quality=0 means no sample at that timestamp per HistorianDataSource.ReadAtTimeAsync. var fake = new FakeHistorian(new HistorianSample { Value = null, Quality = 0, TimestampUtc = DateTime.UtcNow, }); using var backend = BuildBackend(fake, pump); var resp = await backend.HistoryReadAtTimeAsync(new HistoryReadAtTimeRequest { TagReference = "T", TimestampsUtcUnixMs = new[] { 1L }, }, CancellationToken.None); resp.Success.ShouldBeTrue(); resp.Values.Length.ShouldBe(1); resp.Values[0].StatusCode.ShouldBe(0x80000000u); // Bad category resp.Values[0].ValueBytes.ShouldBeNull(); } private sealed class FakeHistorian : IHistorianDataSource { private readonly HistorianSample[] _samples; public int Calls { get; private set; } public DateTime[] LastTimestamps { get; private set; } = Array.Empty(); public FakeHistorian(params HistorianSample[] samples) => _samples = samples; public Task> ReadAtTimeAsync(string tag, DateTime[] ts, CancellationToken ct) { Calls++; LastTimestamps = ts; return Task.FromResult(new List(_samples)); } public Task> ReadRawAsync(string tag, DateTime s, DateTime e, int max, CancellationToken ct) => Task.FromResult(new List()); public Task> ReadAggregateAsync(string tag, DateTime s, DateTime e, double ms, string col, CancellationToken ct) => Task.FromResult(new List()); public Task> ReadEventsAsync(string? src, DateTime s, DateTime e, int max, CancellationToken ct) => Task.FromResult(new List()); public HistorianHealthSnapshot GetHealthSnapshot() => new(); public void Dispose() { } } }