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);
+ }
+}