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:
+149
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user