From 1aa89d754ff65d941690e281ea305d590d87c69f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 13 Jul 2026 10:01:12 -0400 Subject: [PATCH] test(r2-06): HISTGW_BOOL_SANDBOX_TAG fixture + Boolean EnsureTags leg + retype probe (skip-clean offline) (06/U-7) --- ...erverhistorian-failfast-plan.md.tasks.json | 2 +- .../Live/GatewayLiveFixture.cs | 7 + .../Live/GatewayLiveIntegrationTests.cs | 120 ++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json index 2bf733da..54febf58 100644 --- a/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json +++ b/archreview/plans/R2-06-serverhistorian-failfast-plan.md.tasks.json @@ -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" ] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs index eda48b62..25caa3e8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveFixture.cs @@ -22,6 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests.Live; /// HISTGW_GATEWAY_APIKEY — the histgw_<id>_<secret> key (must carry historian:read + historian:write scopes). Required; absent ⇒ all tests skip. /// HISTGW_TEST_TAG — an existing Galaxy / historian tag for the read round-trip. /// HISTGW_WRITE_SANDBOX_TAG — a Float sandbox tag the write round-trip may EnsureTags + write (e.g. HistGW.LiveTest.Sandbox). +/// HISTGW_BOOL_SANDBOX_TAG — a writable Boolean sandbox tag (e.g. HistGW.LiveTest.BoolSandbox) for the 0.2.0 Boolean EnsureTags leg + the retype probe (archreview 06/U-7). Dedicated — never the shared write sandbox. /// HISTGW_ALARM_SOURCE — a source name for the alarm SendEventReadEvents round-trip. /// HISTGW_GATEWAY_ALLOW_UNTRUSTEDtrue to accept a self-signed dev cert (optional). /// @@ -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 /// The Float sandbox tag for the write round-trip (HISTGW_WRITE_SANDBOX_TAG); null when unset. public string? WriteSandboxTag { get; } + /// The dedicated Boolean sandbox tag for the 0.2.0 Boolean EnsureTags leg + retype probe + /// (HISTGW_BOOL_SANDBOX_TAG, archreview 06/U-7); null when unset. + public string? BoolSandboxTag { get; } + /// The source name for the alarm round-trip (HISTGW_ALARM_SOURCE); null when unset. public string? AlarmSource { get; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs index 8479aaa1..96260f1d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/Live/GatewayLiveIntegrationTests.cs @@ -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)."); } + + /// + /// archreview 06/U-7 — Boolean provisioning leg on the 0.2.0 contract. Through the real + /// , EnsureTags a + /// request for the dedicated Boolean sandbox tag (mapped to Int1 with explicit proto3 + /// presence), assert it is ensured (not failed), then WriteLiveValues a 1.0 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 . + /// + [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.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.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}."); + } + + /// + /// archreview 06/U-7 retype probe — answers the assessment note's open question: what does the + /// deployed AVEVA Historian do when EnsureTags (an upsert) is asked to change an + /// existing tag's type? Against the dedicated Boolean sandbox tag only (never the shared + /// Float write sandbox), EnsureTags first as Float then as Int1 and assert the second + /// call's per-tag outcome is deterministic and observable — either Success=true + /// (retype / tag-versioning accepted) or Success=false with a non-empty Error (mapped + /// to the provisioner's failed 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). + /// + [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)."); + } }