Merge re/r1.10-rename-tags: RenameTagsAsync via History StartJob

# Conflicts:
#	docs/plans/hcal-capability-matrix.md
#	docs/plans/hcal-roadmap.md
#	src/AVEVA.Historian.Client/Wcf/HistorianWcfTagWriteOrchestrator.cs
#	tests/AVEVA.Historian.Client.Tests/HistorianClientIntegrationTests.cs
#	tools/AVEVA.Historian.NativeTraceHarness/Program.cs
This commit is contained in:
Joseph Doherty
2026-06-21 16:31:44 -04:00
10 changed files with 694 additions and 0 deletions
@@ -1038,4 +1038,73 @@ public sealed class HistorianClientIntegrationTests
// SQL read-back is diagnostic only; never fail the send test on a query issue.
}
}
[Fact]
public async Task RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag()
{
// Safety: localhost only, names must start with "RetestSdkWrite". Requires the server's
// AllowRenameTags system parameter to be enabled (otherwise StartJob returns false). Gated
// on HISTORIAN_RENAME_SANDBOX so it stays skipped unless explicitly enabled.
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
string? sandbox = Environment.GetEnvironmentVariable("HISTORIAN_RENAME_SANDBOX");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
if (string.IsNullOrWhiteSpace(sandbox) || !sandbox.StartsWith("RetestSdkWrite", StringComparison.Ordinal))
{
return; // safety gate
}
string src = sandbox + "Src";
string dst = sandbox + "Dst";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe
});
// Fresh source tag.
await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = src,
Description = "SDK rename live test",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
MinEU = 0.0,
MaxEU = 100.0,
}, CancellationToken.None);
try
{
AVEVA.Historian.Client.Models.HistorianTagRenameResult result =
await client.RenameTagAsync(src, dst, CancellationToken.None);
Assert.True(result.Accepted, "RenameTagsAsync was not accepted by the server (is AllowRenameTags enabled?).");
Assert.NotEqual(Guid.Empty, result.JobId);
Assert.Equal(1, result.PairCount);
// Rename completes asynchronously; poll the new name's metadata briefly.
bool renamed = false;
for (int i = 0; i < 10 && !renamed; i++)
{
await Task.Delay(500);
try
{
var md = await client.GetTagMetadataAsync(dst, CancellationToken.None);
renamed = md is not null && string.Equals(md.Name, dst, StringComparison.OrdinalIgnoreCase);
}
catch { /* not yet visible */ }
}
Assert.True(renamed, $"Renamed tag '{dst}' did not become visible after the job completed.");
}
finally
{
// Clean up whichever name ended up in the DB.
try { await client.DeleteTagAsync(dst, CancellationToken.None); } catch { }
try { await client.DeleteTagAsync(src, CancellationToken.None); } catch { }
}
}
}
@@ -0,0 +1,62 @@
using System.Buffers.Binary;
using System.Text;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
/// <summary>
/// Golden-byte tests for the History <c>StartJob</c> (StJb) rename job buffer (HCAL R1.10).
/// The reference buffer is the exact byte[] the SDK handed the WCF channel in the live
/// <c>RenameTagsAsync_AgainstLocalHistorian_RenamesSandboxTag</c> run — the server accepted it and
/// the tag was renamed, so it is server-validated, not hand-derived from a chunk-mangled capture.
/// </summary>
public sealed class WcfTagRenameProtocolTests
{
// Server-accepted clean jobBuffer for pairs [("RetestSdkWriteSdkRenameSrc","RetestSdkWriteSdkRenameDst")],
// dumped via AVEVA_HISTORIAN_RENAME_DUMP during the live rename test.
private const string ServerAcceptedJobBufferBase64 =
"AAAAAAAAAAEAAAAaAAAAUgBlAHQAZQBzAHQAUwBkAGsAVwByAGkAdABlAFMAZABrAFIAZQBuAGEAbQBlAFMAcgBjABoAAABSAGUAdABlAHMAdABTAGQAawBXAHIAaQB0AGUAUwBkAGsAUgBlAG4AYQBtAGUARABzAHQA";
[Fact]
public void SerializeRenameJob_MatchesServerAcceptedBuffer()
{
byte[] expected = Convert.FromBase64String(ServerAcceptedJobBufferBase64);
byte[] actual = HistorianTagRenameProtocol.SerializeRenameJob(
[("RetestSdkWriteSdkRenameSrc", "RetestSdkWriteSdkRenameDst")]);
Assert.Equal(expected, actual);
}
[Fact]
public void SerializeRenameJob_SinglePair_HasExpectedLayout()
{
byte[] buf = HistorianTagRenameProtocol.SerializeRenameJob([("ReactorTempA", "ReactorTempB")]);
// 7-byte zero prefix + uint32 pairCount + (uint32 charCount + UTF-16)×2.
Assert.Equal(7 + 4 + 4 + 24 + 4 + 24, buf.Length);
for (int i = 0; i < 7; i++) Assert.Equal(0, buf[i]);
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(7, 4)));
Assert.Equal(12u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(11, 4)));
Assert.Equal("ReactorTempA", Encoding.Unicode.GetString(buf.AsSpan(15, 24)));
Assert.Equal(12u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(39, 4)));
Assert.Equal("ReactorTempB", Encoding.Unicode.GetString(buf.AsSpan(43, 24)));
}
[Fact]
public void SerializeRenameJob_MultiplePairs_EncodesCountAndOrder()
{
byte[] buf = HistorianTagRenameProtocol.SerializeRenameJob(
[("AaOld", "AaNew"), ("BbOld", "BbNew")]);
Assert.Equal(2u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(7, 4)));
// First pair old name immediately follows the count + its length field.
Assert.Equal(5u, BinaryPrimitives.ReadUInt32LittleEndian(buf.AsSpan(11, 4)));
Assert.Equal("AaOld", Encoding.Unicode.GetString(buf.AsSpan(15, 10)));
}
[Fact]
public void SerializeRenameJob_EmptyBatch_Throws()
{
Assert.Throws<ArgumentException>(() =>
HistorianTagRenameProtocol.SerializeRenameJob(Array.Empty<(string, string)>()));
}
}