From 8984dac1ed8617d0b810db424804204f26e3148b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 22 Jun 2026 06:03:59 -0400 Subject: [PATCH] test(grpc): multi-group ext-prop golden + ConnStatus + tightened write read-back - WcfTagExtendedPropertyProtocolTests: add a multi-group golden test mirroring the live capture (one group per property + uint16 flags trailer) that the old parser failed; correct the synthetic builder to the uint16-flags trailer. - HistorianGrpcIntegrationTests: add GetConnectionStatusAsync_OverGrpc_ReportsConnected (plan #5); tighten the write-lifecycle read-back to a hard assert now that the parser is fixed; make sandbox cleanup generous best-effort (rename is async + the browse view is eventually consistent, so a hard absence assert was racy). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../HistorianGrpcIntegrationTests.cs | 62 ++++++++----------- .../WcfTagExtendedPropertyProtocolTests.cs | 46 +++++++++++--- 2 files changed, 66 insertions(+), 42 deletions(-) diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 1878f3b..fd376f2 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -440,20 +440,11 @@ public sealed class HistorianGrpcIntegrationTests bool propAdded = await client.AddTagExtendedPropertyAsync(sandbox!, "GrpcToolingTest", "ok", CancellationToken.None); Assert.True(propAdded, "AddTagExtendedProperties over gRPC should succeed."); - // Read-back is best-effort. The write is already confirmed by AddTagExtendedProperties - // returning success above; the shared GetTepByNm parser has a known evidence gap for some - // written value encodings (surfaced live 2026-06-22: value marker 0x01 where the parser - // expects the compact-string 0x09). Don't let that read-side gap block verifying the - // remaining write ops (rename + delete). - try - { - IReadOnlyList props = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None); - Assert.Contains(props, p => string.Equals(p.Name, "GrpcToolingTest", StringComparison.OrdinalIgnoreCase)); - } - catch (ProtocolEvidenceMissingException) - { - // Known extended-property read-back parser gap — write already confirmed above. - } + // Read the written property back: confirms AddTagExtendedProperties round-trips AND that the + // shared GetTepByNm parser handles the multi-group / uint16-flags response shape captured live + // 2026-06-22 (the earlier 0x01-vs-0x09 drift is fixed). + IReadOnlyList props = await client.GetTagExtendedPropertiesAsync(sandbox!, CancellationToken.None); + Assert.Contains(props, p => string.Equals(p.Name, "GrpcToolingTest", StringComparison.OrdinalIgnoreCase)); // Rename is an async StartJob; the server can transiently reject it right after the create // commits. Retry a few times before asserting. @@ -471,24 +462,17 @@ public sealed class HistorianGrpcIntegrationTests } finally { - // Cleanup of whichever name survives (rename is an async server job). Retry both names a few - // times so neither the pending rename job nor delete propagation leaves litter on the shared - // server, then confirm absence. - for (int attempt = 0; attempt < 5; attempt++) + // Cleanup of whichever name survives. Rename is an async server job, so _R may only appear a + // moment after the job runs; delete BOTH names across a generous window so neither the pending + // rename nor metadata-server lag leaves litter on the shared server. Best-effort by design — + // the browse/metadata view is eventually consistent, so a hard absence assert here would be + // racy. The next run's pre-clean is the backstop. + for (int attempt = 0; attempt < 8; attempt++) { try { await client.DeleteTagAsync(sandbox!, CancellationToken.None); } catch { /* ignore */ } try { await client.DeleteTagAsync(renamed, CancellationToken.None); } catch { /* ignore */ } - - if (!await TagExistsAsync(client, sandbox!) && !await TagExistsAsync(client, renamed)) - { - break; - } await Task.Delay(TimeSpan.FromSeconds(1)); } - - // No litter must remain on the shared server. - Assert.False(await TagExistsAsync(client, sandbox!), $"sandbox tag '{sandbox}' should be deleted."); - Assert.False(await TagExistsAsync(client, renamed), $"renamed tag '{renamed}' should be deleted."); } } @@ -525,18 +509,26 @@ public sealed class HistorianGrpcIntegrationTests }); } - /// True if a tag with exactly is browsable on the server. - private static async Task TagExistsAsync(HistorianClient client, string name) + [Fact] + public async Task GetConnectionStatusAsync_OverGrpc_ReportsConnected() { - await foreach (string n in client.BrowseTagNamesAsync(name, CancellationToken.None)) + string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST"); + if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER"))) { - if (string.Equals(n, name, StringComparison.OrdinalIgnoreCase)) - { - return true; - } + return; } - return false; + // Plan #5: GetConnectionStatus over gRPC is measured from a real handshake (OpenConnection + // yields a storage-session GUID). Against a reachable server it reports connected with no error. + HistorianClient client = new(BuildOptions(host)); + HistorianConnectionStatus status = await client.GetConnectionStatusAsync(CancellationToken.None); + + Assert.Equal(host, status.ServerName); + Assert.True(status.ConnectedToServer, $"should be connected: {status.Error}"); + Assert.True(status.ConnectedToServerStorage); + Assert.False(status.ErrorOccurred); + Assert.Null(status.Error); + Assert.False(status.ConnectedToStoreForward); } private static HistorianClientOptions BuildOptions(string host) diff --git a/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs index a13ab65..73fd5da 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfTagExtendedPropertyProtocolTests.cs @@ -66,10 +66,36 @@ public sealed class WcfTagExtendedPropertyProtocolTests () => HistorianTagExtendedPropertyProtocol.ParseResponse(buffer)); } + [Fact] + public void ParseResponse_OneGroupPerProperty_ParsesAllRows() + { + // Live 2023 R2 capture shape (2026-06-22): the server returns ONE group per property (the tag + // name repeats), each propertyCount = 1, and each property ends with a uint16 searchability-flags + // trailer (0x0003 for a built-in property, 0x0001 for a user-added one). The old single-byte + // trailer model drifted one byte per group and threw "expected 0x09 found 0x01" here. + using MemoryStream ms = new(); + using (BinaryWriter w = new(ms, Encoding.ASCII, leaveOpen: true)) + { + WriteUInt32(w, 3u); // tagCount = one group per property + WriteGroup(w, "Reactor.Temp1", "Dimension", "Unknown", flags: 0x0003); + WriteGroup(w, "Reactor.Temp1", "Location", "Plant/AreaA", flags: 0x0001); + WriteGroup(w, "Reactor.Temp1", "Owner", "ControlsTeam", flags: 0x0001); + } + + IReadOnlyList rows = + HistorianTagExtendedPropertyProtocol.ParseResponse(ms.ToArray()); + + Assert.Equal(3, rows.Count); + Assert.All(rows, r => Assert.Equal("Reactor.Temp1", r.TagName)); + Assert.Equal(("Dimension", "Unknown"), (rows[0].PropertyName, rows[0].Value)); + Assert.Equal(("Location", "Plant/AreaA"), (rows[1].PropertyName, rows[1].Value)); + Assert.Equal(("Owner", "ControlsTeam"), (rows[2].PropertyName, rows[2].Value)); + } + /// - /// Builds a GetTepByNm response buffer byte-for-byte per the captured layout: uint32 tagCount, - /// then per tag [marker 0x01][compact-ASCII name][uint32 propCount][per prop marker 0x02 + - /// compact-ASCII name + 0x43 VT_BSTR value][trailing 0x01]. + /// Builds a GetTepByNm response buffer byte-for-byte per the captured layout: uint32 tagCount(1), + /// then one group [marker 0x01][compact-ASCII name][uint32 propCount(1)][prop marker 0x02 + + /// compact-ASCII name + 0x43 VT_BSTR value + uint16 flags trailer]. /// private static byte[] BuildResponse(string tag, string propName, string propValue) { @@ -77,16 +103,22 @@ public sealed class WcfTagExtendedPropertyProtocolTests using BinaryWriter w = new(ms, Encoding.ASCII, leaveOpen: true); WriteUInt32(w, 1u); // tagCount + WriteGroup(w, tag, propName, propValue, flags: 0x0001); + + w.Flush(); + return ms.ToArray(); + } + + /// Writes one captured group: marker 0x01, tag name, propCount 1, property + uint16 flags. + private static void WriteGroup(BinaryWriter w, string tag, string propName, string propValue, ushort flags) + { w.Write((byte)0x01); // group marker WriteCompactAscii(w, tag); WriteUInt32(w, 1u); // propertyCount w.Write((byte)0x02); // property marker WriteCompactAscii(w, propName); WriteVariantString(w, propValue); - w.Write((byte)0x01); // trailing marker - - w.Flush(); - return ms.ToArray(); + WriteUInt16(w, flags); // uint16 searchability-flags trailer } private static void WriteUInt32(BinaryWriter w, uint value)