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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-22 06:03:59 -04:00
parent 3525653c2b
commit 8984dac1ed
2 changed files with 66 additions and 42 deletions
@@ -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<HistorianTagExtendedProperty> 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<HistorianTagExtendedProperty> 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
});
}
/// <summary>True if a tag with exactly <paramref name="name"/> is browsable on the server.</summary>
private static async Task<bool> 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)
@@ -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<HistorianTagExtendedPropertyRow> 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));
}
/// <summary>
/// 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].
/// </summary>
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();
}
/// <summary>Writes one captured group: marker 0x01, tag name, propCount 1, property + uint16 flags.</summary>
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)