Implement EnsureTagAsync (live-verified) + DeleteTagAsync (DelT semantics partial)

New SDK surface:
  HistorianClient.EnsureTagAsync(HistorianTagDefinition)
  HistorianClient.DeleteTagAsync(string tagName)

Plumbing:
  src/AVEVA.Historian.Client/Models/HistorianTagDefinition.cs
    Public input model — TagName/Description/EngineeringUnit/DataType/MinEU/MaxEU.
    Currently only HistorianDataType.Float is live-verified.

  src/AVEVA.Historian.Client/Wcf/HistorianTagWriteProtocol.cs
    SerializeAnalogCTagMetadata produces 146-byte payload byte-for-byte
    identical to the captured native EnsT2(Float) request.
    SerializeDeleteTagNames produces ushort 0x6751 + ushort 1 + uint count
    + per-tag (uint charCount + UTF-16 chars).

  src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
    Both EnsT2 and DelT run the full Stat-priming chain captured for the
    analog flow (UpdC3 + Stat.GetV ×3 + Stat.GETHI ×2 + 7× GetSystemParameter
    + Trx.GetV + Retr.GetV).

  src/AVEVA.Historian.Client/Wcf/HistorianWcfTagClient.cs
    MapDataType extended to accept tag-origin marker 0xC7 (SDK-created tags).

Tests:
  5 golden-byte tests (HistorianTagWriteProtocolTests):
    SerializeAnalogCTagMetadata byte-for-byte match against captured 146-byte fixture
    SerializeAnalogCTagMetadata produces different bytes for different inputs
    SerializeDeleteTagNames single-tag matches captured shape
    SerializeDeleteTagNames multi-tag appends each
    SerializeDeleteTagNames empty list throws

  1 live integration test (gated by HISTORIAN_WRITE_SANDBOX_TAG):
    EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian
    EnsureTagAsync creates the sandbox tag, GetTagMetadataAsync reads it
    back. 130/130 tests pass.

Harness improvements:
  --write-delete-after now runs DelT independently of AddStreamedValue
  outcome.
  HistorianTagStatusList constructed correctly for DeleteTags reflection
  call (previous StringCollection attempt failed with TypeMismatch).

Known DelT gap: SDK's DeleteTagAsync returns true but server-side
cascading deletion does not always complete (the row remains in
Runtime.dbo.Tag). The captured native flow's DelT removes the tag
cleanly (verified via harness --write-delete-after), so something
around the WCF DelT call is missing from our orchestrator. Documented
as known issue with SMC-based cleanup as workaround.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
dohertj2
2026-05-04 08:33:21 -04:00
parent b3d22befd0
commit cfc8d44e3a
9 changed files with 664 additions and 19 deletions
@@ -301,6 +301,62 @@ public sealed class HistorianClientIntegrationTests
Assert.NotNull(metadata.Key);
}
[Fact]
public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_AgainstLocalHistorian()
{
// Per docs/plans/write-commands-reverse-engineering.md safety rules: localhost only,
// sandbox tag name must start with "RetestSdkWrite", tag is created if missing and
// always deleted at the end so the test leaves zero residue.
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? sandboxTag = Environment.GetEnvironmentVariable("HISTORIAN_WRITE_SANDBOX_TAG");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
if (string.IsNullOrWhiteSpace(sandboxTag) || !sandboxTag.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
return; // safety gate per the plan
}
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new()
{
TagName = sandboxTag,
Description = "SDK live integration test sandbox",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = 0.0,
MaxEU = 100.0,
};
try
{
// EnsureTags2 returns true on fresh creation, false on "already exists with same
// metadata" (per the captured event-flow analog). The success criterion is the
// tag being PRESENT in the DB after the call, not the return value.
_ = await client.EnsureTagAsync(definition, CancellationToken.None);
AVEVA.Historian.Client.Models.HistorianTagMetadata? metadata =
await client.GetTagMetadataAsync(sandboxTag, CancellationToken.None);
Assert.NotNull(metadata);
Assert.Equal(sandboxTag, metadata.Name);
Assert.Equal(AVEVA.Historian.Client.Models.HistorianDataType.Float, metadata.DataType);
}
finally
{
// Cleanup attempt — DeleteTags semantics still under investigation per the
// write-commands plan; even when DelT returns true the deletion may be
// asynchronous on the server. Don't assert post-delete state.
try { _ = await client.DeleteTagAsync(sandboxTag, CancellationToken.None); } catch { }
}
}
[Fact]
public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag()
{
@@ -0,0 +1,95 @@
using System.Text;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
public sealed class HistorianTagWriteProtocolTests
{
[Fact]
public void SerializeAnalogCTagMetadata_MatchesCapturedNativeBytesByteForByte()
{
// Reproduces the captured native EnsT2(Float) CTagMetadata bytes from
// artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/
// fresh-enst2-latest.ndjson — 146 bytes. Inputs:
// tagName = "RetestSdkWriteSandbox" (the sandbox)
// description = "SDK write-RE sandbox tag"
// eu = "test"
// FILETIME = 0x01DCDBBFCD87D049 (captured at run time)
const string ExpectedHex =
"6703000100000004C6020100000000000000000000000000000000" +
"09150052657465737453646B577269746553616E64626F78" +
"FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF" +
"09180053444B2077726974652D52452073616E64626F78207461" +
"670904004D44415302010100000001E803000049D087CDBFDBDC01" +
"1A030904007465737410270000000000000000F03FFE00010101";
byte[] expected = Convert.FromHexString(ExpectedHex);
byte[] actual = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteSandbox",
description: "SDK write-RE sandbox tag",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01DCDBBFCD87D049L));
Assert.Equal(146, expected.Length);
Assert.Equal(146, actual.Length);
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
}
[Fact]
public void SerializeAnalogCTagMetadata_DifferentInputsProducesDifferentBytesInExpectedSlots()
{
DateTime t = new(2026, 5, 4, 12, 0, 0, DateTimeKind.Utc);
byte[] a = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag1", "DescA", "uA", t);
byte[] b = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata("Tag2", "DescB", "uB", t);
Assert.NotEqual(Convert.ToHexString(a), Convert.ToHexString(b));
// First difference must be inside the tagName region (offset 27+ after the 9-byte
// header + 16-byte zero block + 2-byte compact-ASCII len-prefix).
int firstDiff = 0;
while (firstDiff < a.Length && a[firstDiff] == b[firstDiff]) firstDiff++;
Assert.InRange(firstDiff, 25, a.Length);
}
[Fact]
public void SerializeDeleteTagNames_SingleTagMatchesCapturedShape()
{
// Captured DelT.tagNames bytes for ['RetestSdkWriteSandbox']:
// ushort 0x6751 + ushort 1 + uint32 1 + uint32 21 + UTF-16 "RetestSdkWriteSandbox"
// = 12-byte header + 42-byte UTF-16 string = 54 bytes total.
byte[] expected = Concat(
[0x51, 0x67, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x15, 0x00, 0x00, 0x00],
Encoding.Unicode.GetBytes("RetestSdkWriteSandbox"));
byte[] actual = HistorianTagWriteProtocol.SerializeDeleteTagNames(["RetestSdkWriteSandbox"]);
Assert.Equal(54, actual.Length);
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
}
[Fact]
public void SerializeDeleteTagNames_MultipleTagsAppendsEach()
{
byte[] result = HistorianTagWriteProtocol.SerializeDeleteTagNames(["A", "BB", "CCC"]);
// 8-byte header (ushort 0x6751 + ushort 1 + uint32 tagCount)
// + 3 × (uint32 charCount + UTF-16 chars)
// = 8 + (4 + 2) + (4 + 4) + (4 + 6) = 32 bytes
Assert.Equal(32, result.Length);
// Header: 0x6751 + 0x0001 + count=3
Assert.Equal(0x51, result[0]); Assert.Equal(0x67, result[1]);
Assert.Equal(0x01, result[2]); Assert.Equal(0x00, result[3]);
Assert.Equal(3, BitConverter.ToInt32(result, 4));
}
[Fact]
public void SerializeDeleteTagNames_EmptyListThrows()
{
Assert.Throws<ArgumentException>(() => HistorianTagWriteProtocol.SerializeDeleteTagNames([]));
}
private static byte[] Concat(params byte[][] arrays)
{
int total = 0; foreach (byte[] a in arrays) total += a.Length;
byte[] result = new byte[total]; int off = 0;
foreach (byte[] a in arrays) { Buffer.BlockCopy(a, 0, result, off, a.Length); off += a.Length; }
return result;
}
}