Resolve write-path silent fails + expand EnsureTagAsync, RetrievalMode coverage
DelT and EnsT2 had two distinct silent-fail blockers; both now resolved live end-to-end. Read path's RetrievalMode mapping was missing 11 of 15 enum values (plus a latent Cyclic→4 bug). Investigation tooling kept as env-gated helpers. DelT silent fail: Open2 was using NativeIntegratedReadOnlyConnectionMode (0x402); server returned err 132 OperationNotEnabled silently. Added NativeIntegratedWriteEnabledConnectionMode (0x401) per HistorianAccessUtil.SetConnectionMode bit map (Process=1 | IntegratedSecurity=0x400). Write orchestrator now opens with write-enabled mode. EnsT2 silent fail: byte-by-byte comparison via inspector revealed two bugs in SerializeAnalogCTagMetadata. The original "146-byte byte-for-byte match" was misaligned — it omitted the leading 0x4E marker byte and treated WCF's `01 01 01` EndElement closing markers as if they were part of the InBuff payload. Real native InBuff is 144 bytes with 0x4E lead and 2-byte `FE 00` trailer. Golden test bytes corrected. EnsureTagAsync expansion: probed every analog data type via instrument-wcf-writemessage; byte 11 of CTagMetadata is the data-type discriminator (Float=0x01, Double=0x21, UInt2=0x09, UInt4=0x11, Int2=0x29, Int4=0x31). String/Int1/Int8/UInt8 fail at native AddTag — out of scope for this op. Range encoding decoded: defaults emit compact `1A 03`; non-default emit `1F 00` + 4 doubles in order MinEU/MaxEU/MinRaw/MaxRaw. MinRaw/MaxRaw sent on the wire but server mirrors them to MinEU/MaxEU when ApplyScaling=false (verified against native — server quirk, not SDK bug). RetrievalMode mapping: probed all 15 enum values; QueryType is just the native enum ordinal. Replaced the broken switch with `(uint)mode`. Existing SDK mapped Cyclic→4 (BestFit's value); Cyclic is actually 0. CLAUDE.md updated: stale "Active Protocol Blocker" rewritten as resolved-status block; SDK surface now reflects the read-blocker resolution and the new write ops; "Remaining gaps" punch list refreshed. Tools added (both env-gated, no runtime overhead unless flipped on): - HistorianWcfMessageCaptureBehavior — captures all WCF body bytes when AVEVA_HISTORIAN_SDK_WIRE_CAPTURE is set; used for byte-level diff vs native. - HistorianWcfHistAddressingBehavior — explicitly sets wsa:To header on the Hist channel for parity with native bytes (kept though not load-bearing). - WriteDiag in TagWriteOrchestrator — env-gated EnsT2/DelT response logging (AVEVA_HISTORIAN_DELT_DIAG). NativeTraceHarness CLI: added --write-min-eu/--write-max-eu/--write-min-raw/ --write-max-raw for capturing non-default-range EnsT2 payloads. Tests: 130 → 161 passing (+31). Includes 16-mode RetrievalMode mapping table, 4 per-data-type EnsT2 golden tests, NonDefaultRanges golden test, 6 live round-trip integration tests covering Float/Double/Int2/Int4/UInt4/FloatRanges, 3 live tests for previously-unmapped RetrievalMode values. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,48 @@ public sealed class HistorianClientIntegrationTests
|
||||
Assert.All(samples, s => Assert.Equal(AVEVA.Historian.Client.Models.RetrievalMode.TimeWeightedAverage, s.RetrievalMode));
|
||||
}
|
||||
|
||||
// Verifies a previously-unmapped RetrievalMode (one of the 11 modes that prior to
|
||||
// 2026-05-04 threw ProtocolEvidenceMissingException). MinimumWithTime → QueryType=6
|
||||
// exercises the "QueryType is the native enum ordinal" mapping against the live server.
|
||||
[Theory]
|
||||
[InlineData(AVEVA.Historian.Client.Models.RetrievalMode.MinimumWithTime)]
|
||||
[InlineData(AVEVA.Historian.Client.Models.RetrievalMode.MaximumWithTime)]
|
||||
[InlineData(AVEVA.Historian.Client.Models.RetrievalMode.BestFit)]
|
||||
public async Task ReadAggregateAsync_AgainstLocalHistorian_AcceptsPreviouslyUnmappedRetrievalMode(
|
||||
AVEVA.Historian.Client.Models.RetrievalMode mode)
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
string? testTag = Environment.GetEnvironmentVariable("HISTORIAN_TEST_TAG");
|
||||
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(testTag)
|
||||
|| !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
DateTime endUtc = DateTime.UtcNow;
|
||||
DateTime startUtc = endUtc - TimeSpan.FromMinutes(10);
|
||||
|
||||
List<AVEVA.Historian.Client.Models.HistorianAggregateSample> samples = [];
|
||||
await foreach (AVEVA.Historian.Client.Models.HistorianAggregateSample s in client.ReadAggregateAsync(
|
||||
testTag, startUtc, endUtc, mode, TimeSpan.FromMinutes(2), CancellationToken.None))
|
||||
{
|
||||
samples.Add(s);
|
||||
}
|
||||
|
||||
// Server should accept the request without error. Even if no rows come back
|
||||
// (unlikely for a 10-minute window on a steadily-counting tag), the absence of an
|
||||
// exception proves the QueryType byte was accepted.
|
||||
Assert.All(samples, s => Assert.Equal(mode, s.RetrievalMode));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAtTimeAsync_AgainstLocalHistorian_ReturnsRequestedTimestamps()
|
||||
{
|
||||
@@ -335,17 +377,66 @@ public sealed class HistorianClientIntegrationTests
|
||||
MaxEU = 100.0,
|
||||
};
|
||||
|
||||
// EnsureTagAsync's wire bytes match captured native byte-for-byte (golden test
|
||||
// passes), but the call currently returns false and does NOT actually create the
|
||||
// tag — the server-side acceptance criterion the native AddTag flow satisfies is
|
||||
// not yet replicated in our SDK orchestrator. Documented as known issue.
|
||||
// The test below therefore only exercises EnsureTagAsync's call path (verifies it
|
||||
// doesn't throw) and makes a best-effort cleanup via DeleteTagAsync.
|
||||
await client.EnsureTagAsync(definition, CancellationToken.None);
|
||||
// Both EnsureTagAsync and DeleteTagAsync now work end-to-end against the live
|
||||
// Historian. Open2 must use write-enabled connectionMode 0x401 (not the default
|
||||
// 0x402 read-only); the EnsT2 InBuff layout is corrected to native parity (144
|
||||
// bytes incl 0x4E leading marker, no trailing 01 01 01 closing markers).
|
||||
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
|
||||
Assert.True(ensured, "EnsureTagAsync returned false against the live Historian.");
|
||||
|
||||
// Best-effort cleanup. May return false if EnsureTagAsync didn't actually create
|
||||
// the tag (per the known issue) — that's expected, not a test failure.
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
bool deleted = await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
Assert.True(deleted, "DeleteTagAsync returned false against the live Historian.");
|
||||
}
|
||||
|
||||
// Round-trip every live-verified analog data type + the non-default-range case. The
|
||||
// sandbox tag name is suffixed per case so the runs don't collide. Always cleans up.
|
||||
[Theory]
|
||||
[InlineData("RetestSdkWriteFloatRT", AVEVA.Historian.Client.Models.HistorianDataType.Float, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteDoubleRT", AVEVA.Historian.Client.Models.HistorianDataType.Double, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteInt2RT", AVEVA.Historian.Client.Models.HistorianDataType.Int2, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteInt4RT", AVEVA.Historian.Client.Models.HistorianDataType.Int4, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteUInt4RT", AVEVA.Historian.Client.Models.HistorianDataType.UInt4, 0.0, 100.0, 0.0, 100.0)]
|
||||
[InlineData("RetestSdkWriteFloatRangesRT", AVEVA.Historian.Client.Models.HistorianDataType.Float, -50.0, 200.0, 10.0, 4095.0)]
|
||||
public async Task EnsureTagAsync_AndDeleteTagAsync_RoundTrip_PerDataTypeAndRange(
|
||||
string sandboxTag,
|
||||
AVEVA.Historian.Client.Models.HistorianDataType dataType,
|
||||
double minEU, double maxEU, double minRaw, double maxRaw)
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe
|
||||
});
|
||||
|
||||
AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new()
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = $"SDK round-trip {dataType}",
|
||||
EngineeringUnit = "test",
|
||||
DataType = dataType,
|
||||
MinEU = minEU,
|
||||
MaxEU = maxEU,
|
||||
MinRaw = minRaw,
|
||||
MaxRaw = maxRaw,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
|
||||
Assert.True(ensured, $"EnsureTagAsync({dataType}) returned false against the live Historian.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Always clean up — DeleteTagAsync returns true on a freshly-created tag.
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user