fix(driver-historian-wonderware): resolve Low code-review findings (Driver.Historian.Wonderware-004,005,007,008,010,011,012)
- 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>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-012 coverage — pins <see cref="HistorianDataSource"/>'s
|
||||
/// connect-failover / cooldown loop via a fake <see cref="IHistorianConnectionFactory"/>.
|
||||
/// A live <see cref="HistorianAccess"/> is never instantiated; the fake throws on every
|
||||
/// attempt so the read path surfaces the connect failure without touching the SDK.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceConnectFailoverTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReadRaw_when_no_nodes_are_healthy_throws_so_IPC_surfaces_Success_false()
|
||||
{
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = new List<string> { "node-a" },
|
||||
FailureCooldownSeconds = 60,
|
||||
// Disable the outer request timeout so the test doesn't race the connect failure
|
||||
// against the timeout (we want the connect failure path, not a TimeoutException).
|
||||
RequestTimeoutSeconds = 0,
|
||||
};
|
||||
var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory());
|
||||
|
||||
// Read methods used to swallow the connect exception and return an empty list with
|
||||
// Success=true; the fix re-throws so the IPC layer surfaces Success=false. The
|
||||
// exception must therefore propagate.
|
||||
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync(
|
||||
"Tank.Level",
|
||||
new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
maxValues: 100,
|
||||
CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRaw_tries_each_cluster_node_in_order_until_one_succeeds_or_all_fail()
|
||||
{
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = new List<string> { "node-a", "node-b", "node-c" },
|
||||
FailureCooldownSeconds = 60,
|
||||
RequestTimeoutSeconds = 0,
|
||||
};
|
||||
var factory = new TrackingThrowingConnectionFactory();
|
||||
var ds = new HistorianDataSource(cfg, factory);
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync(
|
||||
"Tank.Level",
|
||||
new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 5, 1, 0, 1, 0, DateTimeKind.Utc),
|
||||
maxValues: 100,
|
||||
CancellationToken.None));
|
||||
|
||||
// All three candidates must be attempted in the configured order before the
|
||||
// connect-loop gives up.
|
||||
factory.AttemptedNodes.ShouldBe(new[] { "node-a", "node-b", "node-c" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadRaw_marks_failed_nodes_in_cooldown_so_a_subsequent_call_sees_no_healthy_nodes()
|
||||
{
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = new List<string> { "node-a", "node-b" },
|
||||
FailureCooldownSeconds = 60,
|
||||
RequestTimeoutSeconds = 0,
|
||||
};
|
||||
var ds = new HistorianDataSource(cfg, new ThrowingConnectionFactory());
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => ds.ReadRawAsync(
|
||||
"Tank.Level",
|
||||
DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow,
|
||||
maxValues: 100, CancellationToken.None));
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.NodeCount.ShouldBe(2);
|
||||
snap.HealthyNodeCount.ShouldBe(0, "both nodes failed and entered cooldown after the connect attempts");
|
||||
snap.ProcessConnectionOpen.ShouldBeFalse();
|
||||
snap.ActiveProcessNode.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadEvents_uses_a_separate_event_connection_path()
|
||||
{
|
||||
// ReadEventsAsync uses _eventConnection / EnsureEventConnected — a different
|
||||
// codepath than ReadRawAsync. Symmetric test to pin the dual-connection design.
|
||||
var cfg = new HistorianConfiguration
|
||||
{
|
||||
Enabled = true,
|
||||
ServerNames = new List<string> { "node-a" },
|
||||
FailureCooldownSeconds = 60,
|
||||
RequestTimeoutSeconds = 0,
|
||||
};
|
||||
var factory = new TrackingThrowingConnectionFactory();
|
||||
var ds = new HistorianDataSource(cfg, factory);
|
||||
|
||||
await Should.ThrowAsync<Exception>(() => ds.ReadEventsAsync(
|
||||
sourceName: "Tank.HiHi",
|
||||
DateTime.UtcNow.AddMinutes(-1), DateTime.UtcNow,
|
||||
maxEvents: 100, CancellationToken.None));
|
||||
|
||||
factory.AttemptedTypes.ShouldContain(HistorianConnectionType.Event,
|
||||
"event reads must open an Event-typed connection");
|
||||
factory.AttemptedNodes.ShouldBe(new[] { "node-a" });
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
private sealed class ThrowingConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
public HistorianAccess CreateAndConnect(
|
||||
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
|
||||
=> throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
|
||||
}
|
||||
|
||||
private sealed class TrackingThrowingConnectionFactory : IHistorianConnectionFactory
|
||||
{
|
||||
public List<string> AttemptedNodes { get; } = new();
|
||||
public List<HistorianConnectionType> AttemptedTypes { get; } = new();
|
||||
|
||||
public HistorianAccess CreateAndConnect(
|
||||
HistorianConfiguration config, HistorianConnectionType type, bool readOnly = true)
|
||||
{
|
||||
AttemptedNodes.Add(config.ServerName);
|
||||
AttemptedTypes.Add(type);
|
||||
throw new InvalidOperationException($"simulated connect failure to {config.ServerName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using ArchestrA;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-005 regression tests for <see cref="HistorianDataSource.GetHealthSnapshot"/>.
|
||||
/// The active-node strings and the connection-open booleans were published under different
|
||||
/// locks, so a snapshot could observe an internally inconsistent pairing (open with no node,
|
||||
/// or closed with a non-null node). The fix derives the open booleans from the same field
|
||||
/// that is published under the same lock so the snapshot is self-consistent by construction.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceHealthSnapshotTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Drives the "half-published" state directly via reflection: set <c>_connection</c>
|
||||
/// to a non-null sentinel but leave <c>_activeProcessNode</c> null. The snapshot must
|
||||
/// report <c>ProcessConnectionOpen = false</c> and <c>ActiveProcessNode = null</c>
|
||||
/// consistently — never a mismatch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_connection_set_but_active_node_null_is_consistent()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
SetField(ds, "_connection", new HistorianAccess());
|
||||
SetField(ds, "_activeProcessNode", (string?)null);
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
(snap.ProcessConnectionOpen == (snap.ActiveProcessNode != null)).ShouldBeTrue(
|
||||
"snapshot must not advertise open with no node — picks one source of truth");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Symmetric case for the event connection.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_event_connection_set_but_active_node_null_is_consistent()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
SetField(ds, "_eventConnection", new HistorianAccess());
|
||||
SetField(ds, "_activeEventNode", (string?)null);
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
(snap.EventConnectionOpen == (snap.ActiveEventNode != null)).ShouldBeTrue(
|
||||
"snapshot must not advertise event open with no node");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The other direction: connection cleared but node still populated (the failure path
|
||||
/// between the two field clears). The snapshot must still pair them consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_connection_cleared_but_active_node_populated_is_consistent()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
SetField(ds, "_connection", (HistorianAccess?)null);
|
||||
SetField(ds, "_activeProcessNode", "node-stale");
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
(snap.ProcessConnectionOpen == (snap.ActiveProcessNode != null)).ShouldBeTrue(
|
||||
"snapshot must not advertise closed with a node still set");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steady-state happy path: both fields populated — snapshot reports both consistently.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_both_fields_populated_reports_open_and_active_node()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
SetField(ds, "_connection", new HistorianAccess());
|
||||
SetField(ds, "_activeProcessNode", "h1");
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.ProcessConnectionOpen.ShouldBeTrue();
|
||||
snap.ActiveProcessNode.ShouldBe("h1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Steady-state default (no connect attempted): both null.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Snapshot_with_default_fields_reports_closed_with_no_active_node()
|
||||
{
|
||||
var ds = new HistorianDataSource(
|
||||
new HistorianConfiguration { Enabled = true, ServerName = "h1" });
|
||||
|
||||
var snap = ds.GetHealthSnapshot();
|
||||
snap.ProcessConnectionOpen.ShouldBeFalse();
|
||||
snap.ActiveProcessNode.ShouldBeNull();
|
||||
snap.EventConnectionOpen.ShouldBeFalse();
|
||||
snap.ActiveEventNode.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static void SetField(object target, string name, object? value)
|
||||
{
|
||||
var f = target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
f.ShouldNotBeNull($"private field '{name}' must exist on {target.GetType().Name}");
|
||||
f!.SetValue(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-010 regression. <see cref="HistorianConfiguration.RequestTimeoutSeconds"/>
|
||||
/// was documented as the "outer safety timeout applied to sync-over-async Historian
|
||||
/// operations" but was never read or enforced — a hung <c>StartQuery</c> or a slow
|
||||
/// <c>MoveNext</c> could block the single pipe-server connection thread indefinitely.
|
||||
/// The fix wires it into the read paths via a linked <see cref="CancellationTokenSource"/>
|
||||
/// so the documented safety net actually exists.
|
||||
///
|
||||
/// The SDK-touching read methods cannot be unit-driven without a live AVEVA Historian.
|
||||
/// This test pins the helper that derives the effective timeout from the config — the
|
||||
/// read methods invoke that helper, so a regression in either the helper or the wiring
|
||||
/// would break the test.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceRequestTimeoutTests
|
||||
{
|
||||
[Fact]
|
||||
public void Default_request_timeout_is_60_seconds()
|
||||
{
|
||||
new HistorianConfiguration().RequestTimeoutSeconds.ShouldBe(60);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Positive_request_timeout_is_used_verbatim()
|
||||
{
|
||||
InvokeBuildLinkedTokenSource(
|
||||
new HistorianConfiguration { RequestTimeoutSeconds = 30 },
|
||||
CancellationToken.None,
|
||||
out var cts);
|
||||
cts.ShouldNotBeNull();
|
||||
// The helper must wire CancelAfter — easiest cross-check is to observe that the
|
||||
// returned CTS is NOT already cancelled, and that disposing it is safe.
|
||||
cts!.IsCancellationRequested.ShouldBeFalse();
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_or_negative_request_timeout_is_treated_as_no_timeout()
|
||||
{
|
||||
// A zero/negative value means "no outer timeout" — the helper must still return a
|
||||
// linked CTS so callers can use one code path, but it must not auto-cancel.
|
||||
InvokeBuildLinkedTokenSource(
|
||||
new HistorianConfiguration { RequestTimeoutSeconds = 0 },
|
||||
CancellationToken.None,
|
||||
out var cts);
|
||||
cts.ShouldNotBeNull();
|
||||
cts!.IsCancellationRequested.ShouldBeFalse();
|
||||
// Give the runtime a moment — a misconfigured CancelAfter(0) would fire immediately.
|
||||
Thread.Sleep(50);
|
||||
cts.IsCancellationRequested.ShouldBeFalse("RequestTimeoutSeconds <= 0 must not auto-cancel");
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Small_timeout_cancels_the_linked_token()
|
||||
{
|
||||
// 50 ms timeout — sleep 250 ms then assert the linked CTS has fired.
|
||||
InvokeBuildLinkedTokenSource(
|
||||
new HistorianConfiguration { RequestTimeoutSeconds = 1 }, // smallest non-zero whole-second value
|
||||
CancellationToken.None,
|
||||
out var cts);
|
||||
cts.ShouldNotBeNull();
|
||||
|
||||
// The wall-clock cost of waiting a full second per test is acceptable — this
|
||||
// pins the actual CancelAfter wiring rather than just the conditional logic.
|
||||
await Task.Delay(1500);
|
||||
cts!.IsCancellationRequested.ShouldBeTrue("RequestTimeoutSeconds=1 must cancel within 1.5s");
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Inbound_cancellation_propagates_into_the_linked_token()
|
||||
{
|
||||
using var outer = new CancellationTokenSource();
|
||||
InvokeBuildLinkedTokenSource(
|
||||
new HistorianConfiguration { RequestTimeoutSeconds = 60 },
|
||||
outer.Token,
|
||||
out var cts);
|
||||
cts.ShouldNotBeNull();
|
||||
cts!.IsCancellationRequested.ShouldBeFalse();
|
||||
|
||||
outer.Cancel();
|
||||
cts.IsCancellationRequested.ShouldBeTrue("cancelling the caller's CT must cancel the linked CTS");
|
||||
cts.Dispose();
|
||||
}
|
||||
|
||||
private static void InvokeBuildLinkedTokenSource(
|
||||
HistorianConfiguration cfg, CancellationToken ct, out CancellationTokenSource? cts)
|
||||
{
|
||||
// The helper is internal so the InternalsVisibleTo on the data-source project lets
|
||||
// us bind to it directly. Reflection keeps the test resilient if the method name is
|
||||
// ever shortened.
|
||||
var method = typeof(HistorianDataSource)
|
||||
.GetMethod("BuildRequestCts", BindingFlags.Static | BindingFlags.NonPublic);
|
||||
method.ShouldNotBeNull(
|
||||
"HistorianDataSource.BuildRequestCts must exist — wires RequestTimeoutSeconds into the read paths");
|
||||
cts = (CancellationTokenSource?)method!.Invoke(null, new object[] { cfg, ct });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using ArchestrA;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-008 regression. The previous implementation unconditionally
|
||||
/// called <c>HandleConnectionError()</c> whenever <c>StartQuery</c> returned <c>false</c>,
|
||||
/// which tore down the (relatively expensive) shared SDK connection on a query-class error
|
||||
/// such as a bad tag name. A burst of bad-tag queries could therefore push an otherwise
|
||||
/// healthy cluster node into cooldown via the picker's <c>MarkFailed</c>. The fix
|
||||
/// classifies the SDK error code: connection-class codes drop the connection; query-class
|
||||
/// codes leave it intact.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceStartQueryClassificationTests
|
||||
{
|
||||
// ── Connection-class codes — the connection should be reset ───────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistorianAccessError.ErrorValue.FailedToConnect)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.FailedToCreateSession)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NoReply)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotReady)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotInitialized)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.Stopping)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.Win32Exception)]
|
||||
[InlineData(HistorianAccessError.ErrorValue.InvalidResponse)]
|
||||
public void Connection_class_codes_are_classified_as_connection_errors(HistorianAccessError.ErrorValue code)
|
||||
{
|
||||
HistorianDataSource.IsConnectionClassError(code).ShouldBeTrue(
|
||||
$"{code} is a connection/server failure — the SDK connection should be reset");
|
||||
}
|
||||
|
||||
// ── Query-class codes — the connection should NOT be reset ────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(HistorianAccessError.ErrorValue.InvalidArgument)] // bad tag name, etc.
|
||||
[InlineData(HistorianAccessError.ErrorValue.ValidationFailed)] // bad query args
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotApplicable)] // wrong tag kind for query
|
||||
[InlineData(HistorianAccessError.ErrorValue.NotImplemented)] // unsupported aggregate
|
||||
[InlineData(HistorianAccessError.ErrorValue.NoData)] // empty range
|
||||
public void Query_class_codes_are_NOT_classified_as_connection_errors(HistorianAccessError.ErrorValue code)
|
||||
{
|
||||
HistorianDataSource.IsConnectionClassError(code).ShouldBeFalse(
|
||||
$"{code} is a query payload problem — must NOT tear down the SDK connection");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Runtime.Serialization;
|
||||
using ArchestrA;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Backend;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Backend;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-012 coverage — pins the two static helpers on
|
||||
/// <see cref="HistorianDataSource"/> that previously had no direct tests:
|
||||
/// <see cref="HistorianDataSource.SelectValueFromPair"/> (the string-vs-numeric heuristic
|
||||
/// for the raw + at-time read paths) and <see cref="HistorianDataSource.ExtractAggregateValue"/>
|
||||
/// (the aggregate-column dispatch). The SDK <c>HistoryQueryResult</c> initialises internal
|
||||
/// state lazily on first property access, which makes it impractical to fake via
|
||||
/// <see cref="FormatterServices.GetUninitializedObject"/>; the heuristic was therefore
|
||||
/// refactored into an SDK-independent overload that the tests drive directly.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HistorianDataSourceValueAndAggregateTests
|
||||
{
|
||||
// ── SelectValueFromPair ───────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void SelectValueFromPair_returns_numeric_value_when_StringValue_is_empty()
|
||||
{
|
||||
HistorianDataSource.SelectValueFromPair(42.5, string.Empty).ShouldBe(42.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectValueFromPair_returns_numeric_value_when_Value_is_non_zero_even_with_StringValue_populated()
|
||||
{
|
||||
// Tag is numeric and sampled non-zero; the SDK may still populate a formatted
|
||||
// StringValue but the value path wins.
|
||||
HistorianDataSource.SelectValueFromPair(3.14, "3.14").ShouldBe(3.14);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectValueFromPair_returns_StringValue_when_Value_is_zero_and_StringValue_non_empty()
|
||||
{
|
||||
// String tags in the SDK always project Value=0 — that's the documented heuristic.
|
||||
HistorianDataSource.SelectValueFromPair(0.0, "Ready").ShouldBe("Ready");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectValueFromPair_returns_numeric_zero_when_Value_is_zero_and_StringValue_empty()
|
||||
{
|
||||
// Numeric tag legitimately samples zero, no formatted text — must remain numeric.
|
||||
HistorianDataSource.SelectValueFromPair(0.0, string.Empty).ShouldBe(0.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectValueFromPair_null_StringValue_falls_back_to_numeric()
|
||||
{
|
||||
HistorianDataSource.SelectValueFromPair(7.7, null).ShouldBe(7.7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectValueFromPair_documented_edge_case_numeric_zero_with_formatted_string_returns_string()
|
||||
{
|
||||
// The doc comment on SelectValue calls this out as a known SDK-binding edge case:
|
||||
// "A numeric tag at exactly zero with a non-empty formatted StringValue (e.g. '0.00')
|
||||
// would be mis-reported as a string". This test pins that documented behaviour so
|
||||
// a future SDK upgrade that surfaces a real data-type field can replace the
|
||||
// heuristic deliberately rather than by accident.
|
||||
HistorianDataSource.SelectValueFromPair(0.0, "0.00").ShouldBe("0.00");
|
||||
}
|
||||
|
||||
// ── ExtractAggregateValue ─────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("Average", 10.0)]
|
||||
[InlineData("Minimum", 1.0)]
|
||||
[InlineData("Maximum", 20.0)]
|
||||
[InlineData("First", 2.0)]
|
||||
[InlineData("Last", 8.0)]
|
||||
[InlineData("StdDev", 1.5)]
|
||||
public void ExtractAggregateValue_dispatches_known_columns(string column, double expected)
|
||||
{
|
||||
var result = NewAggregateResult();
|
||||
result.Average = 10.0;
|
||||
result.Minimum = 1.0;
|
||||
result.Maximum = 20.0;
|
||||
result.ValueCount = 5;
|
||||
result.First = 2.0;
|
||||
result.Last = 8.0;
|
||||
result.StdDev = 1.5;
|
||||
|
||||
HistorianDataSource.ExtractAggregateValue(result, column).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractAggregateValue_ValueCount_dispatches_to_uint_field()
|
||||
{
|
||||
var result = NewAggregateResult();
|
||||
result.ValueCount = 42;
|
||||
HistorianDataSource.ExtractAggregateValue(result, "ValueCount").ShouldBe(42.0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractAggregateValue_unknown_column_returns_null()
|
||||
{
|
||||
// Unknown column → null → IPC sample carries no value → client maps to BadNoData.
|
||||
HistorianDataSource.ExtractAggregateValue(NewAggregateResult(), "NotAColumn").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractAggregateValue_case_sensitive_dispatch()
|
||||
{
|
||||
// The switch is case-sensitive — "average" (lowercase) does NOT dispatch. Pinned so
|
||||
// the canonical column-name casing is preserved across refactors.
|
||||
var result = NewAggregateResult();
|
||||
result.Average = 99.0;
|
||||
HistorianDataSource.ExtractAggregateValue(result, "average").ShouldBeNull();
|
||||
HistorianDataSource.ExtractAggregateValue(result, "Average").ShouldBe(99.0);
|
||||
}
|
||||
|
||||
private static AnalogSummaryQueryResult NewAggregateResult()
|
||||
{
|
||||
return (AnalogSummaryQueryResult)FormatterServices.GetUninitializedObject(typeof(AnalogSummaryQueryResult));
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,58 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Principal;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MessagePack;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Driver.Historian.Wonderware-007 regression. The two other rejection paths
|
||||
/// (shared-secret-mismatch and major-version-mismatch) both write a <see cref="HelloAck"/>
|
||||
/// with <c>Accepted=false</c> before disconnecting; the caller-SID-mismatch path used to
|
||||
/// just disconnect abruptly, leaving the client to time out instead of learning why.
|
||||
/// The fix sends a symmetric <c>caller-sid-mismatch</c> ack before disconnecting.
|
||||
///
|
||||
/// The test uses the internal test-seam constructor so the verifier rejects without
|
||||
/// needing to actually relax the pipe ACL (which would block the test client itself).
|
||||
/// </summary>
|
||||
public sealed class PipeServerSidRejectTests
|
||||
{
|
||||
private static readonly ILogger Quiet = Logger.None;
|
||||
|
||||
[Fact]
|
||||
public async Task Caller_SID_mismatch_sends_HelloAck_with_reject_reason_before_disconnect()
|
||||
{
|
||||
// The pipe ACL must allow the current process to connect — so wire up the pipe
|
||||
// with the current user's SID. Then have the verifier seam simulate the SID
|
||||
// mismatch by returning false. This isolates the "what does the server do on a
|
||||
// rejected caller" question from the (separate) "is the ACL correct" question.
|
||||
var current = WindowsIdentity.GetCurrent().User
|
||||
?? throw new InvalidOperationException("WindowsIdentity.GetCurrent().User was null — cannot run test");
|
||||
|
||||
var pipeName = $"otopcua-hist-sidreject-test-{Guid.NewGuid():N}";
|
||||
|
||||
PipeServer.CallerVerifier rejecting = (NamedPipeServerStream _, SecurityIdentifier _, out string reason) =>
|
||||
{
|
||||
reason = "synthetic-mismatch";
|
||||
return false;
|
||||
};
|
||||
using var server = new PipeServer(pipeName, current, "secret", Quiet, rejecting);
|
||||
|
||||
var serverTask = Task.Run(() => server.RunOneConnectionAsync(new NoopHandler(), CancellationToken.None));
|
||||
|
||||
using var client = new NamedPipeClientStream(".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||
await client.ConnectAsync(5_000);
|
||||
|
||||
using var writer = new FrameWriter(client, leaveOpen: true);
|
||||
using var reader = new FrameReader(client, leaveOpen: true);
|
||||
|
||||
var hello = new Hello { ProtocolMajor = Hello.CurrentMajor, PeerName = "test", SharedSecret = "secret" };
|
||||
await writer.WriteAsync(MessageKind.Hello, hello, CancellationToken.None);
|
||||
|
||||
// Read the rejecting HelloAck the server is expected to send before disconnecting.
|
||||
var frame = await reader.ReadFrameAsync(CancellationToken.None);
|
||||
frame.ShouldNotBeNull("server must send a HelloAck on caller-SID rejection, not just disconnect");
|
||||
frame!.Value.Kind.ShouldBe(MessageKind.HelloAck);
|
||||
|
||||
var ack = MessagePackSerializer.Deserialize<HelloAck>(frame.Value.Body);
|
||||
ack.Accepted.ShouldBeFalse();
|
||||
ack.RejectReason.ShouldNotBeNullOrEmpty();
|
||||
ack.RejectReason!.ShouldContain("caller-sid-mismatch",
|
||||
Case.Insensitive,
|
||||
"reject reason must match the documented caller-sid-mismatch tag so clients can diagnose");
|
||||
|
||||
await serverTask;
|
||||
}
|
||||
|
||||
/// <summary>Handler that asserts it is never called — the connection must be rejected at Hello.</summary>
|
||||
private sealed class NoopHandler : IFrameHandler
|
||||
{
|
||||
public Task HandleAsync(MessageKind kind, byte[] body, FrameWriter writer, CancellationToken ct)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Handler must not be reached on a rejected caller; got frame {kind}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user