Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Tests/Backend/HistorianDataSourceValueAndAggregateTests.cs
Joseph Doherty 1f29b215c8 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>
2026-05-23 08:18:10 -04:00

123 lines
5.1 KiB
C#

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));
}
}