- Driver.Historian.Wonderware-004: ToHistorianEvent synthesises a fresh
Guid when the upstream EventId is unparseable and logs the substitution
instead of writing the historian with Guid.Empty.
- Driver.Historian.Wonderware-005: GetHealthSnapshot derives the
connection-open booleans from the active-node fields so the snapshot
is self-consistent without depending on the secondary lock.
- Driver.Historian.Wonderware-007: SID-mismatch branch in PipeServer now
sends a HelloAck { Accepted=false, RejectReason } so the client sees a
symmetric rejection.
- Driver.Historian.Wonderware-008: classify StartQuery failures —
connection-class codes drop the connection, query-class codes throw
QueryClassStartQueryException so the IPC layer surfaces Success=false.
- Driver.Historian.Wonderware-010: RequestTimeoutSeconds now enforced
via BuildRequestCts linked to the caller's CancellationToken.
- Driver.Historian.Wonderware-011: refreshed XML docs to describe the
current sidecar / named-pipe architecture (Galaxy.Host / Proxy
references reframed as historical context).
- Driver.Historian.Wonderware-012: pinned the previously-uncovered
HistorianDataSource behaviours with five new test files; also removed
the stale empty tests/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests
directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
305 lines
14 KiB
C#
305 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using ArchestrA;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
|
{
|
|
/// <summary>
|
|
/// PR C.1 — covers <see cref="SdkAlarmHistorianWriteBackend"/>, the aahClientManaged-bound
|
|
/// alarm-event writer. The SDK-touching batch loop itself is exercised by the rig-gated
|
|
/// <c>Live_*</c> tests (D.1); the unit tests below pin the parts that are SDK-type-free:
|
|
/// <list type="bullet">
|
|
/// <item><description>connection-unavailable → whole batch deferred as RetryPlease;</description></item>
|
|
/// <item><description><see cref="SdkAlarmHistorianWriteBackend.ClassifyOutcome"/> error-code mapping;</description></item>
|
|
/// <item><description><see cref="SdkHistorianConnectionFactory.BuildConnectionArgs"/> read-only-vs-write shaping.</description></item>
|
|
/// </list>
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class SdkAlarmHistorianWriteBackendTests
|
|
{
|
|
// ── Connection-unavailable path (deterministic, no SDK load) ──────────
|
|
|
|
[Fact]
|
|
public async Task Empty_batch_returns_empty_array()
|
|
{
|
|
var backend = new SdkAlarmHistorianWriteBackend(
|
|
Config("any"), new ThrowingConnectionFactory());
|
|
|
|
var outcomes = await backend.WriteBatchAsync(
|
|
Array.Empty<AlarmHistorianEventDto>(), CancellationToken.None);
|
|
|
|
outcomes.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unreachable_node_defers_whole_batch_as_RetryPlease()
|
|
{
|
|
// No node can be connected — the backend must defer every event so the
|
|
// lmxopcua-side SQLite store-and-forward sink retains the rows rather than
|
|
// dropping them.
|
|
var backend = new SdkAlarmHistorianWriteBackend(
|
|
Config("unreachable"), new ThrowingConnectionFactory());
|
|
|
|
var events = new[] { AlarmEvent("E1"), AlarmEvent("E2"), AlarmEvent("E3") };
|
|
var outcomes = await backend.WriteBatchAsync(events, CancellationToken.None);
|
|
|
|
outcomes.Length.ShouldBe(events.Length);
|
|
outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unreachable_node_large_batch_returns_one_outcome_per_event()
|
|
{
|
|
// Guards the outcome-array allocation: WriteBatchAsync must always return exactly
|
|
// as many outcomes as input events, even on the whole-batch-deferred path.
|
|
var backend = new SdkAlarmHistorianWriteBackend(
|
|
Config("unreachable"), new ThrowingConnectionFactory());
|
|
|
|
var batch = Enumerable.Range(0, 1000).Select(i => AlarmEvent($"E{i}")).ToArray();
|
|
var outcomes = await backend.WriteBatchAsync(batch, CancellationToken.None);
|
|
|
|
outcomes.Length.ShouldBe(1000);
|
|
outcomes.ShouldAllBe(o => o == AlarmHistorianWriteOutcome.RetryPlease);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Connect_failure_marks_node_failed_in_picker()
|
|
{
|
|
// Every connect attempt throws → the picker should record the failure so the
|
|
// node enters cooldown (cluster-failover plumbing).
|
|
var cfg = Config("node-a");
|
|
var picker = new HistorianClusterEndpointPicker(cfg);
|
|
var backend = new SdkAlarmHistorianWriteBackend(cfg, new ThrowingConnectionFactory(), picker);
|
|
|
|
await backend.WriteBatchAsync(new[] { AlarmEvent("E1") }, CancellationToken.None);
|
|
|
|
picker.HealthyNodeCount.ShouldBe(0, "the only node failed to connect and is now in cooldown");
|
|
}
|
|
|
|
// ── ClassifyOutcome — error-code → outcome mapping ────────────────────
|
|
|
|
[Theory]
|
|
[InlineData(HistorianAccessError.ErrorValue.Success, AlarmHistorianWriteOutcome.Ack)]
|
|
[InlineData(HistorianAccessError.ErrorValue.FailedToConnect, AlarmHistorianWriteOutcome.RetryPlease)]
|
|
[InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession, AlarmHistorianWriteOutcome.RetryPlease)]
|
|
[InlineData(HistorianAccessError.ErrorValue.NoReply, AlarmHistorianWriteOutcome.RetryPlease)]
|
|
[InlineData(HistorianAccessError.ErrorValue.NotReady, AlarmHistorianWriteOutcome.RetryPlease)]
|
|
[InlineData(HistorianAccessError.ErrorValue.Failure, AlarmHistorianWriteOutcome.RetryPlease)]
|
|
[InlineData(HistorianAccessError.ErrorValue.NoData, AlarmHistorianWriteOutcome.RetryPlease)]
|
|
[InlineData(HistorianAccessError.ErrorValue.InvalidArgument, AlarmHistorianWriteOutcome.PermanentFail)]
|
|
[InlineData(HistorianAccessError.ErrorValue.ValidationFailed, AlarmHistorianWriteOutcome.PermanentFail)]
|
|
[InlineData(HistorianAccessError.ErrorValue.NullPointerArgument, AlarmHistorianWriteOutcome.PermanentFail)]
|
|
[InlineData(HistorianAccessError.ErrorValue.NotImplemented, AlarmHistorianWriteOutcome.PermanentFail)]
|
|
public void ClassifyOutcome_maps_error_code_to_expected_outcome(
|
|
HistorianAccessError.ErrorValue code, AlarmHistorianWriteOutcome expected)
|
|
{
|
|
SdkAlarmHistorianWriteBackend.ClassifyOutcome(code).ShouldBe(expected);
|
|
}
|
|
|
|
// ── ToHistorianEvent — EventId handling ───────────────────────────────
|
|
|
|
[Fact]
|
|
public void ToHistorianEvent_parseable_event_id_is_used_verbatim()
|
|
{
|
|
// Sanity case: a real GUID round-trips into HistorianEvent.Id.
|
|
var id = Guid.Parse("12345678-1234-1234-1234-123456789abc");
|
|
var dto = new AlarmHistorianEventDto
|
|
{
|
|
EventId = id.ToString(),
|
|
SourceName = "Tank01",
|
|
AlarmType = "AnalogLimitAlarm.HiHi",
|
|
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
|
|
};
|
|
|
|
#pragma warning disable CS0618
|
|
SdkAlarmHistorianWriteBackend.ToHistorianEvent(dto).Id.ShouldBe(id);
|
|
#pragma warning restore CS0618
|
|
}
|
|
|
|
[Fact]
|
|
public void ToHistorianEvent_unparseable_event_id_synthesizes_unique_non_empty_Guid()
|
|
{
|
|
// Driver.Historian.Wonderware-004 regression: when EventId is not a parseable
|
|
// GUID (or is empty) the previous implementation silently left HistorianEvent.Id
|
|
// as Guid.Empty, so multiple alarms collided on the same id with no warning.
|
|
// The fix synthesizes a fresh Guid so every event still gets a unique identifier.
|
|
var dtoA = new AlarmHistorianEventDto
|
|
{
|
|
EventId = "not-a-guid",
|
|
SourceName = "Tank01",
|
|
AlarmType = "Active",
|
|
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
|
|
};
|
|
var dtoB = new AlarmHistorianEventDto
|
|
{
|
|
EventId = string.Empty,
|
|
SourceName = "Tank01",
|
|
AlarmType = "Active",
|
|
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
|
|
};
|
|
|
|
#pragma warning disable CS0618
|
|
var idA = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoA).Id;
|
|
var idB = SdkAlarmHistorianWriteBackend.ToHistorianEvent(dtoB).Id;
|
|
#pragma warning restore CS0618
|
|
|
|
idA.ShouldNotBe(Guid.Empty, "unparseable EventId must not collapse to Guid.Empty");
|
|
idB.ShouldNotBe(Guid.Empty, "empty EventId must not collapse to Guid.Empty");
|
|
idA.ShouldNotBe(idB, "every event needs a unique synthesized id");
|
|
}
|
|
|
|
[Fact]
|
|
public void ClassifyOutcome_WriteToReadOnlyFile_is_RetryPlease_not_PermanentFail()
|
|
{
|
|
// Driver.Historian.Wonderware-001 regression: WriteToReadOnlyFile is a
|
|
// connection-configuration fault (the write session was opened without
|
|
// ReadOnly = false), NOT a malformed-event fault. Routing it to PermanentFail
|
|
// would dead-letter every alarm event in the batch on a misconfigured/regressed
|
|
// connection — data loss. It must be treated as a transient connection-class
|
|
// error so the events are deferred and retried once the connection is corrected.
|
|
SdkAlarmHistorianWriteBackend.ClassifyOutcome(
|
|
HistorianAccessError.ErrorValue.WriteToReadOnlyFile)
|
|
.ShouldBe(AlarmHistorianWriteOutcome.RetryPlease);
|
|
}
|
|
|
|
// ── BuildConnectionArgs — read-only vs write shaping ──────────────────
|
|
|
|
[Fact]
|
|
public void BuildConnectionArgs_write_connection_is_not_read_only()
|
|
{
|
|
// The alarm-event write path must open ReadOnly=false; AddStreamedValue on a
|
|
// read-only session fails with WriteToReadOnlyFile.
|
|
var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
|
|
Config("h1"), HistorianConnectionType.Event, readOnly: false);
|
|
|
|
args.ReadOnly.ShouldBeFalse();
|
|
args.ConnectionType.ShouldBe(HistorianConnectionType.Event);
|
|
args.ServerName.ShouldBe("h1");
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildConnectionArgs_query_connection_is_read_only()
|
|
{
|
|
var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
|
|
Config("h1"), HistorianConnectionType.Process, readOnly: true);
|
|
|
|
args.ReadOnly.ShouldBeTrue();
|
|
args.ConnectionType.ShouldBe(HistorianConnectionType.Process);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildConnectionArgs_non_integrated_security_carries_credentials()
|
|
{
|
|
var cfg = Config("h1");
|
|
cfg.IntegratedSecurity = false;
|
|
cfg.UserName = "histuser";
|
|
cfg.Password = "histpass";
|
|
|
|
var args = SdkHistorianConnectionFactory.BuildConnectionArgs(
|
|
cfg, HistorianConnectionType.Event, readOnly: false);
|
|
|
|
args.IntegratedSecurity.ShouldBeFalse();
|
|
args.UserName.ShouldBe("histuser");
|
|
args.Password.ShouldBe("histpass");
|
|
}
|
|
|
|
// ── Rig-gated integration tests ───────────────────────────────────────
|
|
//
|
|
// The entry point (HistorianAccess.AddStreamedValue) is pinned and implemented;
|
|
// these need a live AVEVA Historian and are un-skipped during the PR D.1 smoke.
|
|
|
|
[Fact(Skip = "rig-required: needs a live AVEVA Historian — un-skip during the PR D.1 rollout smoke")]
|
|
public async Task Live_single_event_roundtrip_returns_Ack()
|
|
{
|
|
var backend = new SdkAlarmHistorianWriteBackend(BuildRigConfig());
|
|
|
|
var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-E1") }, CancellationToken.None);
|
|
|
|
outcomes.Length.ShouldBe(1);
|
|
outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack);
|
|
}
|
|
|
|
[Fact(Skip = "rig-required: needs a live AVEVA Historian cluster (two nodes) — un-skip during the PR D.1 rollout smoke")]
|
|
public async Task Live_cluster_failover_primary_bad_rotates_to_secondary()
|
|
{
|
|
var cfg = new HistorianConfiguration
|
|
{
|
|
Enabled = true,
|
|
ServerNames = new List<string>
|
|
{
|
|
"invalid-primary-node-deliberately-unreachable",
|
|
Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
|
|
},
|
|
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
|
|
IntegratedSecurity = true,
|
|
FailureCooldownSeconds = 5,
|
|
CommandTimeoutSeconds = 10,
|
|
};
|
|
var backend = new SdkAlarmHistorianWriteBackend(cfg);
|
|
|
|
var outcomes = await backend.WriteBatchAsync(new[] { AlarmEvent("rig-failover-E1") }, CancellationToken.None);
|
|
|
|
outcomes.Length.ShouldBe(1);
|
|
outcomes[0].ShouldBe(AlarmHistorianWriteOutcome.Ack);
|
|
}
|
|
|
|
// ── helpers ───────────────────────────────────────────────────────────
|
|
|
|
private static HistorianConfiguration Config(string server) => new HistorianConfiguration
|
|
{
|
|
Enabled = true,
|
|
ServerName = server,
|
|
Port = 32568,
|
|
IntegratedSecurity = true,
|
|
CommandTimeoutSeconds = 30,
|
|
FailureCooldownSeconds = 60,
|
|
};
|
|
|
|
private static AlarmHistorianEventDto AlarmEvent(string id) => new AlarmHistorianEventDto
|
|
{
|
|
EventId = id,
|
|
SourceName = "TestSource",
|
|
ConditionId = "TestSource.Level.HiHi",
|
|
AlarmType = "AnalogLimitAlarm.HiHi",
|
|
Message = "C.1 test alarm",
|
|
Severity = 500,
|
|
EventTimeUtcTicks = DateTime.UtcNow.Ticks,
|
|
AckComment = null,
|
|
};
|
|
|
|
private static HistorianConfiguration BuildRigConfig() => new HistorianConfiguration
|
|
{
|
|
Enabled = true,
|
|
ServerName = Environment.GetEnvironmentVariable("OTOPCUA_HISTORIAN_SERVER") ?? "localhost",
|
|
Port = TryParseInt("OTOPCUA_HISTORIAN_PORT", 32568),
|
|
IntegratedSecurity = true,
|
|
CommandTimeoutSeconds = 30,
|
|
FailureCooldownSeconds = 60,
|
|
};
|
|
|
|
private static int TryParseInt(string envName, int defaultValue)
|
|
{
|
|
var raw = Environment.GetEnvironmentVariable(envName);
|
|
return int.TryParse(raw, out var parsed) ? parsed : defaultValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fake factory whose every connect attempt throws — drives the
|
|
/// connection-unavailable path without loading the native SDK.
|
|
/// </summary>
|
|
private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory
|
|
{
|
|
public HistorianAccess CreateAndConnect(
|
|
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
|
|
=> throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
|
|
}
|
|
}
|
|
}
|