From a86a09767c430822afddb77c90c65ca38d41ebd8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 10 Aug 2026 06:53:47 -0400 Subject: [PATCH] test(historian-gateway): cover GatewayHistorianValueWriter + HistorizationOutboxEntrySerializer offline Driver.Historian.Gateway-003 (Medium): both were exercised only by the env-gated Category=LiveIntegration suite (skips cleanly offline) or only indirectly through FasterLogHistorizationOutboxTests with single-char ASCII tags. Adds direct offline unit coverage for the value writer's ack-to-bool mapping, timestamp presence/absence, and non-throwing contract (via the existing FakeHistorianGatewayClient double), and for the serializer's Serialize/Deserialize round trip incl. a multi-byte UTF-8 tag, an empty tag, and ushort/double boundaries. --- .../GatewayHistorianValueWriterTests.cs | 149 ++++++++++++++++++ ...HistorizationOutboxEntrySerializerTests.cs | 73 +++++++++ 2 files changed, 222 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/GatewayHistorianValueWriterTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/HistorizationOutboxEntrySerializerTests.cs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/GatewayHistorianValueWriterTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/GatewayHistorianValueWriterTests.cs new file mode 100644 index 00000000..3367cfa2 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/GatewayHistorianValueWriterTests.cs @@ -0,0 +1,149 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Recorder; + +/// +/// Driver.Historian.Gateway-003 (offline unit coverage for the continuous-historization write path): +/// before this file, — the recorder's WriteLiveValues +/// adapter, in production the write path CLAUDE.md's #491/#537/#538 live-gate history bit hard — was +/// exercised only by the env-gated Category=LiveIntegration suite, which skips cleanly offline. +/// These tests use the same the rest of the module's offline +/// tests share. +/// +public sealed class GatewayHistorianValueWriterTests +{ + private static GatewayHistorianValueWriter Writer(FakeHistorianGatewayClient fake) => + new(fake, NullLogger.Instance); + + [Fact] + public async Task Success_ack_returns_true() + { + var fake = new FakeHistorianGatewayClient { WriteLiveValuesResult = new WriteAck { Success = true } }; + var values = new[] { new HistorizationValue(DateTime.UtcNow, 42.0, 192) }; + + var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken); + + Assert.True(ok); + Assert.Equal(1, fake.WriteLiveValuesCallCount); + Assert.Equal("T", fake.LastWriteLiveTag); + } + + [Fact] + public async Task Queued_ack_is_treated_as_success() + { + // A value the gateway durably store-forward-queued must not be re-drained by the recorder. + var fake = new FakeHistorianGatewayClient + { + WriteLiveValuesResult = new WriteAck { Success = false, Queued = true }, + }; + var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) }; + + var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken); + + Assert.True(ok); + } + + [Fact] + public async Task Soft_failure_ack_returns_false_for_retry() + { + var fake = new FakeHistorianGatewayClient + { + WriteLiveValuesResult = new WriteAck { Success = false, Queued = false }, + }; + var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) }; + + var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken); + + Assert.False(ok); + } + + [Fact] + public async Task Gateway_exception_is_swallowed_and_returns_false() + { + // NEVER throw out of the writer — the recorder's drain loop expects a plain bool. + var fake = new FakeHistorianGatewayClient { WriteLiveValuesThrows = new InvalidOperationException("boom") }; + var values = new[] { new HistorizationValue(DateTime.UtcNow, 1.0, 192) }; + + var ok = await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken); + + Assert.False(ok); + } + + [Fact] + public async Task Empty_batch_is_trivially_acked_without_calling_the_gateway() + { + var fake = new FakeHistorianGatewayClient(); + + var ok = await Writer(fake).WriteLiveValuesAsync( + "T", Array.Empty(), TestContext.Current.CancellationToken); + + Assert.True(ok); + Assert.Equal(0, fake.WriteLiveValuesCallCount); + } + + [Fact] + public async Task Present_timestamp_is_sent_and_absent_timestamp_leaves_the_proto_field_unset() + { + var fake = new FakeHistorianGatewayClient(); + var stamped = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var values = new[] + { + new HistorizationValue(stamped, 1.0, 192), + new HistorizationValue(null, 2.0, 192), + }; + + await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken); + + var sent = fake.LastWriteLiveValues!; + Assert.Equal(2, sent.Count); + Assert.Equal(stamped, sent[0].Timestamp!.ToDateTime()); + Assert.Null(sent[1].Timestamp); // absent -> unset proto field -> gateway server-stamps now + } + + [Fact] + public async Task Quality_and_numeric_value_are_forwarded_unchanged() + { + var fake = new FakeHistorianGatewayClient(); + var values = new[] { new HistorizationValue(null, 123.5, 192) }; + + await Writer(fake).WriteLiveValuesAsync("T", values, TestContext.Current.CancellationToken); + + var sent = fake.LastWriteLiveValues!; + Assert.Equal(123.5, sent[0].NumericValue); + Assert.Equal(192u, sent[0].Quality); + } + + [Fact] + public async Task Null_tag_throws() + { + var fake = new FakeHistorianGatewayClient(); + var values = new[] { new HistorizationValue(null, 1.0, 192) }; + + await Assert.ThrowsAsync(() => + Writer(fake).WriteLiveValuesAsync(null!, values, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Null_values_throws() + { + var fake = new FakeHistorianGatewayClient(); + + await Assert.ThrowsAsync(() => + Writer(fake).WriteLiveValuesAsync("T", null!, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DisposeAsync_disposes_underlying_client() + { + var fake = new FakeHistorianGatewayClient(); + var writer = Writer(fake); + + await writer.DisposeAsync(); + + Assert.Equal(1, fake.DisposeCallCount); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/HistorizationOutboxEntrySerializerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/HistorizationOutboxEntrySerializerTests.cs new file mode 100644 index 00000000..0efdc6ae --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Recorder/HistorizationOutboxEntrySerializerTests.cs @@ -0,0 +1,73 @@ +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Recorder; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Recorder; + +/// +/// Driver.Historian.Gateway-003 (offline unit coverage): +/// exercises only indirectly, and only with short +/// ASCII tag names ("A"/"B"/"C"). The hand-rolled little-endian layout computes its tag byte-length +/// from Encoding.UTF8.GetByteCount before writing, so a multi-byte tag exercises the one part +/// of the layout arithmetic the indirect coverage never reaches. This asserts the (de)serializer +/// directly, in isolation from FasterLog. +/// +public sealed class HistorizationOutboxEntrySerializerTests +{ + [Fact] + public void Round_trips_all_fields() + { + var entry = new HistorizationOutboxEntry( + Guid.NewGuid(), "Pump1.Temp", 123.456, 192, + new DateTime(2026, 1, 1, 12, 34, 56, DateTimeKind.Utc)); + + var bytes = HistorizationOutboxEntrySerializer.Serialize(entry); + var round = HistorizationOutboxEntrySerializer.Deserialize(bytes); + + Assert.Equal(entry.Id, round.Id); + Assert.Equal(entry.Tag, round.Tag); + Assert.Equal(entry.NumericValue, round.NumericValue); + Assert.Equal(entry.Quality, round.Quality); + Assert.Equal(entry.TimestampUtc, round.TimestampUtc); + Assert.Equal(entry.TimestampUtc.Kind, round.TimestampUtc.Kind); // ToBinary/FromBinary round-trips Kind + } + + [Fact] + public void Round_trips_a_multi_byte_utf8_tag() + { + // The tag length prefix is a UTF-8 BYTE count, not a char count — a tag whose char count and + // byte count diverge (e.g. Ω is 2 bytes, 日 is 3) is the one case that would desync the layout + // if GetByteCount/GetBytes ever disagreed with what Deserialize slices out. + var entry = new HistorizationOutboxEntry( + Guid.NewGuid(), "Pumpe_Ω_日本語_Temp", -1.5, 0, DateTime.UtcNow); + + var round = HistorizationOutboxEntrySerializer.Deserialize( + HistorizationOutboxEntrySerializer.Serialize(entry)); + + Assert.Equal(entry.Tag, round.Tag); + Assert.Equal(entry.NumericValue, round.NumericValue); + } + + [Fact] + public void Round_trips_an_empty_tag() + { + var entry = new HistorizationOutboxEntry(Guid.NewGuid(), string.Empty, 0.0, 0, DateTime.UtcNow); + + var round = HistorizationOutboxEntrySerializer.Deserialize( + HistorizationOutboxEntrySerializer.Serialize(entry)); + + Assert.Equal(string.Empty, round.Tag); + } + + [Fact] + public void Round_trips_boundary_quality_and_negative_value() + { + var entry = new HistorizationOutboxEntry(Guid.NewGuid(), "T", double.MinValue, ushort.MaxValue, DateTime.UtcNow); + + var round = HistorizationOutboxEntrySerializer.Deserialize( + HistorizationOutboxEntrySerializer.Serialize(entry)); + + Assert.Equal(ushort.MaxValue, round.Quality); + Assert.Equal(double.MinValue, round.NumericValue); + } +}