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.
This commit is contained in:
Joseph Doherty
2026-08-10 06:53:47 -04:00
parent 55b12dc217
commit a86a09767c
2 changed files with 222 additions and 0 deletions
@@ -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;
/// <summary>
/// Driver.Historian.Gateway-003 (offline unit coverage for the continuous-historization write path):
/// before this file, <see cref="GatewayHistorianValueWriter"/> — the recorder's <c>WriteLiveValues</c>
/// adapter, in production the write path CLAUDE.md's #491/#537/#538 live-gate history bit hard — was
/// exercised only by the env-gated <c>Category=LiveIntegration</c> suite, which skips cleanly offline.
/// These tests use the same <see cref="FakeHistorianGatewayClient"/> the rest of the module's offline
/// tests share.
/// </summary>
public sealed class GatewayHistorianValueWriterTests
{
private static GatewayHistorianValueWriter Writer(FakeHistorianGatewayClient fake) =>
new(fake, NullLogger<GatewayHistorianValueWriter>.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<HistorizationValue>(), 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<ArgumentNullException>(() =>
Writer(fake).WriteLiveValuesAsync(null!, values, TestContext.Current.CancellationToken));
}
[Fact]
public async Task Null_values_throws()
{
var fake = new FakeHistorianGatewayClient();
await Assert.ThrowsAsync<ArgumentNullException>(() =>
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);
}
}
@@ -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;
/// <summary>
/// Driver.Historian.Gateway-003 (offline unit coverage): <see cref="FasterLogHistorizationOutboxTests"/>
/// exercises <see cref="HistorizationOutboxEntrySerializer"/> only indirectly, and only with short
/// ASCII tag names ("A"/"B"/"C"). The hand-rolled little-endian layout computes its tag byte-length
/// from <c>Encoding.UTF8.GetByteCount</c> 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.
/// </summary>
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);
}
}