test(r2-06): HISTGW_BOOL_SANDBOX_TAG fixture + Boolean EnsureTags leg + retype probe (skip-clean offline) (06/U-7)

This commit is contained in:
Joseph Doherty
2026-07-13 10:01:12 -04:00
parent dc2f3fa970
commit 1aa89d754f
3 changed files with 128 additions and 1 deletions
@@ -57,7 +57,7 @@
{
"id": "T8",
"subject": "U-7 live leg: HISTGW_BOOL_SANDBOX_TAG in GatewayLiveFixture + EnsureTags_boolean_sandbox_provisions_as_Int1 + EnsureTags_type_change_outcome_is_observable retype probe (skip-clean offline; verify all-skip run)",
"status": "pending",
"status": "completed",
"blockedBy": [
"T7"
]
@@ -22,6 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Live;
/// <item><c>HISTGW_GATEWAY_APIKEY</c> — the <c>histgw_&lt;id&gt;_&lt;secret&gt;</c> key (must carry <c>historian:read</c> + <c>historian:write</c> scopes). Required; absent ⇒ all tests skip.</item>
/// <item><c>HISTGW_TEST_TAG</c> — an existing Galaxy / historian tag for the read round-trip.</item>
/// <item><c>HISTGW_WRITE_SANDBOX_TAG</c> — a Float sandbox tag the write round-trip may <c>EnsureTags</c> + write (e.g. <c>HistGW.LiveTest.Sandbox</c>).</item>
/// <item><c>HISTGW_BOOL_SANDBOX_TAG</c> — a writable Boolean sandbox tag (e.g. <c>HistGW.LiveTest.BoolSandbox</c>) for the 0.2.0 Boolean <c>EnsureTags</c> leg + the retype probe (archreview 06/U-7). Dedicated — never the shared write sandbox.</item>
/// <item><c>HISTGW_ALARM_SOURCE</c> — a source name for the alarm <c>SendEvent</c> → <c>ReadEvents</c> round-trip.</item>
/// <item><c>HISTGW_GATEWAY_ALLOW_UNTRUSTED</c> — <c>true</c> to accept a self-signed dev cert (optional).</item>
/// </list>
@@ -44,6 +45,7 @@ public sealed class GatewayLiveFixture
private const string EnvApiKey = "HISTGW_GATEWAY_APIKEY";
private const string EnvTestTag = "HISTGW_TEST_TAG";
private const string EnvWriteSandboxTag = "HISTGW_WRITE_SANDBOX_TAG";
private const string EnvBoolSandboxTag = "HISTGW_BOOL_SANDBOX_TAG";
private const string EnvAlarmSource = "HISTGW_ALARM_SOURCE";
private const string EnvAllowUntrusted = "HISTGW_GATEWAY_ALLOW_UNTRUSTED";
@@ -68,6 +70,7 @@ public sealed class GatewayLiveFixture
_apiKey = Trimmed(EnvApiKey);
TestTag = Trimmed(EnvTestTag);
WriteSandboxTag = Trimmed(EnvWriteSandboxTag);
BoolSandboxTag = Trimmed(EnvBoolSandboxTag);
AlarmSource = Trimmed(EnvAlarmSource);
_allowUntrusted = string.Equals(
Trimmed(EnvAllowUntrusted), "true", StringComparison.OrdinalIgnoreCase);
@@ -105,6 +108,10 @@ public sealed class GatewayLiveFixture
/// <summary>The Float sandbox tag for the write round-trip (<c>HISTGW_WRITE_SANDBOX_TAG</c>); null when unset.</summary>
public string? WriteSandboxTag { get; }
/// <summary>The dedicated Boolean sandbox tag for the 0.2.0 Boolean EnsureTags leg + retype probe
/// (<c>HISTGW_BOOL_SANDBOX_TAG</c>, archreview 06/U-7); null when unset.</summary>
public string? BoolSandboxTag { get; }
/// <summary>The source name for the alarm round-trip (<c>HISTGW_ALARM_SOURCE</c>); null when unset.</summary>
public string? AlarmSource { get; }
@@ -253,4 +253,124 @@ public sealed class GatewayLiveIntegrationTests(GatewayLiveFixture fixture) : IC
HistorianWriteOutcome.Ack,
"the alarm SendEvent must be acked (the AlarmEventMapper must NOT set the wire event Id — the gateway rejects a client-supplied id).");
}
/// <summary>
/// archreview 06/U-7 — Boolean provisioning leg on the 0.2.0 contract. Through the <b>real</b>
/// <see cref="GatewayTagProvisioner"/>, <c>EnsureTags</c> a <see cref="DriverDataType.Boolean"/>
/// request for the dedicated Boolean sandbox tag (mapped to <c>Int1</c> with explicit proto3
/// presence), assert it is ensured (not failed), then <c>WriteLiveValues</c> a <c>1.0</c> and read
/// it back over a wide window. This retires "the live EnsureTags leg has never run on 0.2.0" for
/// the Boolean path. The ±12h read window + timezone caveat mirror <see cref="Write_then_read_on_sandbox_tag"/>.
/// </summary>
[Fact]
[Trait("Category", "LiveIntegration")]
public async Task EnsureTags_boolean_sandbox_provisions_as_Int1()
{
if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
if (_fx.BoolSandboxTag is null)
Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a writable Boolean sandbox tag (e.g. HistGW.LiveTest.BoolSandbox) to run the 0.2.0 Boolean EnsureTags leg.");
var ct = TestContext.Current.CancellationToken;
var tag = _fx.BoolSandboxTag;
// EnsureTags (Boolean → Int1) through the REAL provisioner seam (the same code path deploys use).
await using var writeClient = _fx.CreateClient();
var provisioner = new GatewayTagProvisioner(writeClient, NullLogger<GatewayTagProvisioner>.Instance);
var provision = await provisioner.EnsureTagsAsync(
new[] { new HistorianTagProvisionRequest(tag, DriverDataType.Boolean, null, "OtOpcUa live validation Boolean sandbox") },
ct);
provision.Ensured.ShouldBe(1,
"the Boolean sandbox tag must be ensured (needs the gateway on 0.2.0 contracts + an API key carrying historian:tags:write).");
provision.Failed.ShouldBe(0);
// WriteLiveValues a Boolean-as-1.0 and read it back (SQL live path can lag a flush cadence → poll).
const ushort goodQuality = 192; // OPC-DA "Good" floor.
var writeUtc = DateTime.UtcNow;
const double written = 1.0;
var valueWriter = new GatewayHistorianValueWriter(writeClient, NullLogger<GatewayHistorianValueWriter>.Instance);
var acked = await valueWriter.WriteLiveValuesAsync(
tag, new[] { new HistorizationValue(writeUtc, written, goodQuality) }, ct);
acked.ShouldBeTrue("the Boolean live write must be acked (gateway RuntimeDb:Enabled=true + EnsureTags-provisioned tag).");
await using var dataSource = _fx.CreateDataSource();
DataValueSnapshot? hit = null;
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
do
{
var read = await dataSource.ReadRawAsync(
tag, writeUtc - TimeSpan.FromHours(12), writeUtc + TimeSpan.FromHours(12), maxValuesPerNode: 50_000, ct);
hit = read.Samples.FirstOrDefault(s => s.Value is double d && Math.Abs(d - written) < 0.5);
if (hit is not null) break;
await Task.Delay(TimeSpan.FromSeconds(1), ct);
}
while (DateTime.UtcNow < deadline);
hit.ShouldNotBeNull($"the written Boolean sample ({written}) should read back from '{tag}'.");
TestContext.Current.SendDiagnosticMessage(
$"Boolean round-trip: EnsureTags(Int1) + WriteLiveValues '{tag}'={written} → read back at {hit!.SourceTimestampUtc:O}.");
}
/// <summary>
/// archreview 06/U-7 retype probe — answers the assessment note's open question: what does the
/// deployed AVEVA Historian do when <c>EnsureTags</c> (an <b>upsert</b>) is asked to change an
/// existing tag's type? Against the <b>dedicated Boolean sandbox tag only</b> (never the shared
/// Float write sandbox), EnsureTags first as <c>Float</c> then as <c>Int1</c> and assert the second
/// call's per-tag outcome is <em>deterministic and observable</em> — either <c>Success=true</c>
/// (retype / tag-versioning accepted) or <c>Success=false</c> with a non-empty <c>Error</c> (mapped
/// to the provisioner's <c>failed</c> tally). The assertion accepts both because the contract
/// guarantees only the observability, not the policy; the observed behavior is emitted as a
/// diagnostic so the run documents which of accepted-versioned / accepted-retyped / rejected the
/// historian exhibits (fed back into docs/Historian.md after the first live run).
/// </summary>
[Fact]
[Trait("Category", "LiveIntegration")]
public async Task EnsureTags_type_change_outcome_is_observable()
{
if (_fx.NotConfigured) Assert.Skip(_fx.SkipReason!);
if (_fx.BoolSandboxTag is null)
Assert.Skip("Skipped: set HISTGW_BOOL_SANDBOX_TAG to a dedicated Boolean sandbox tag to run the retype probe.");
var ct = TestContext.Current.CancellationToken;
var tag = _fx.BoolSandboxTag;
await using var client = _fx.CreateClient();
// First establish the tag as Float, then ask EnsureTags to retype it to Int1 (Boolean).
await client.EnsureTagsAsync(
new[]
{
new HistorianTagDefinition
{
TagName = tag,
DataType = HistorianDataType.Float,
EngineeringUnit = string.Empty,
Description = "OtOpcUa live validation retype probe (stage 1: Float)",
},
},
ct);
var retype = await client.EnsureTagsAsync(
new[]
{
new HistorianTagDefinition
{
TagName = tag,
DataType = HistorianDataType.Int1,
EngineeringUnit = string.Empty,
Description = "OtOpcUa live validation retype probe (stage 2: Int1)",
},
},
ct);
var outcome = retype.Results.ShouldHaveSingleItem();
var observable = outcome.Success || !string.IsNullOrEmpty(outcome.Error);
observable.ShouldBeTrue(
"the retype outcome must be deterministic + observable: either Success=true (retype/version accepted) " +
"or Success=false with a non-empty Error (mapped to the provisioner's failed tally).");
TestContext.Current.SendDiagnosticMessage(
$"retype probe '{tag}' Float→Int1: Success={outcome.Success}, Error='{outcome.Error}'. " +
"(records the deployed historian's in-place analog-retype policy — feed this back into docs/Historian.md).");
}
}