Files
lmxopcua/tests/Drivers/Historian.Conformance/IConformanceBackend.cs
T
Joseph Doherty 56bb4855bb fix(historian): #671 review — raw-row cap, reverse Interpolative, SDK status rule, Partial bit
M1: the time-weighted family (incl. Total, formerly a pushed-down SUM) streams
raw window rows into the C# evaluator. New shared knob
ServerHistorian:MaxTimeWeightedRawRows (default 1,000,000; <=0 unbounded with a
Validate warning) flows through HistorianBackendRegistry into both providers.
The window query is capped at cap+1 (SQL Server TOP (@maxRows), QuestDB LIMIT $4);
one row past the cap throws the new Core.Abstractions
HistoryReadLimitExceededException, which the node manager maps to
BadTooManyOperations for that node. Not counted as a backend health failure;
bound rows are not counted; per-sample aggregates are not capped.

M2: reverse Interpolative is anchored at the later time (Part 13): points at
End - i*interval, emitted latest first. 10:00->09:00 @1h yields 10:00.

L1: Partial bit (0x4, = Opc.Ua.AggregateBits.Partial) on non-Bad results of a
truncated last interval (time-weighted family; per-sample family is a follow-up).

L2/L3: aligned with the SDK AggregateCalculator.GetTimeBasedStatusCode
(decompiled from Opc.Ua.Server 1.5.378.106): TreatUncertainAsBad=false counts
Uncertain time (Uncertain samples, held-into-outage, extrapolation) as Good
time; data but no non-Bad time => generic Bad (0x80000000); BadNoData only
when there is no data at all. Consequence: a live tag's latest interval reads
Good (documented).

L5: release note no longer claims Total matches the Gateway Integral exactly
(AVEVA's divisor is unverified); it notes Total is now evaluated in C# and
subject to the cap.

Tests: unit (reverse Interpolative x3, Partial, Bad vs BadNoData incl. an
open-ended outage, cap helpers, SDK bit oracles); conformance (reverse
Interpolative, raw-row cap at/over/0, bounds not counted, health unaffected);
node manager BadTooManyOperations; options default + warning. Each new
behaviour falsified by mutation (aggregator x9, data-source cap checks on
both backends against the real DBs, node-manager catch).
2026-10-11 09:10:26 -04:00

94 lines
4.7 KiB
C#

using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Conformance;
/// <summary>
/// One historian backend under test. The conformance suite talks to a backend only through this contract
/// (plus Core.Abstractions), so the same hand-computed expectations run against SQL Server and QuestDB.
/// Each instance owns an isolated store (a fresh schema / table set) so tests never see each other's data.
/// </summary>
public interface IConformanceBackend : IAsyncDisposable
{
/// <summary>Storage timestamp resolution in .NET ticks: 1 for SQL Server (100 ns), 10 for QuestDB (µs).</summary>
long ResolutionTicks { get; }
/// <summary>Truncates a timestamp to the backend's storage resolution (UTC kind). Every timestamp assertion goes through it.</summary>
/// <param name="t">The timestamp.</param>
/// <returns>The value the backend stores/serves for <paramref name="t"/>.</returns>
DateTime Round(DateTime t) => new(t.Ticks - t.Ticks % ResolutionTicks, DateTimeKind.Utc);
/// <summary>A fresh data source (fresh catalog cache) over this backend's store.</summary>
/// <param name="clock">Its "now"; null means the system clock.</param>
IHistorianDataSource CreateDataSource(TimeProvider? clock = null);
/// <summary>A fresh data source whose time-weighted raw-row cap (<c>ServerHistorian:MaxTimeWeightedRawRows</c>) is <paramref name="maxTimeWeightedRawRows"/>.</summary>
/// <param name="maxTimeWeightedRawRows">The cap (&lt;= 0 = unbounded).</param>
IHistorianDataSource CreateDataSource(int maxTimeWeightedRawRows);
/// <summary>A fresh live-value writer over this backend's store.</summary>
/// <param name="utcNow">The clock used to stamp values that carry no timestamp; null means the system clock.</param>
IHistorianValueWriter CreateValueWriter(Func<DateTime>? utcNow = null);
/// <summary>A fresh history updater over this backend's store.</summary>
IHistorianDataUpdater CreateUpdater();
/// <summary>
/// Writes rows directly in the backend's schema (never through the writer or updater), status verbatim, creating the
/// tag if needed. The stored server timestamp of each row is its source time + 1 s, so tests can tell them apart.
/// </summary>
/// <param name="tag">The tag name.</param>
/// <param name="samples">The rows to store.</param>
Task SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples);
/// <summary>
/// Live (non-deleted) rows for a tag, ascending by time, read directly from storage. The status is returned
/// VERBATIM as stored — there is no read-side clamp (an Empty row stored non-Bad reads back non-Bad here).
/// </summary>
/// <param name="tag">The tag name.</param>
Task<IReadOnlyList<(DateTime T, HistorianValue V, uint Status)>> ReadBackAsync(string tag);
/// <summary>Whether a tag with exactly this name exists in storage (no truncation of long names).</summary>
/// <param name="tag">The tag name.</param>
Task<bool> TagExistsAsync(string tag);
/// <summary>The data type the backend recorded for a tag, or null when the tag does not exist.</summary>
/// <param name="tag">The tag name.</param>
Task<DriverDataType?> TagDataTypeAsync(string tag);
}
/// <summary>
/// Base of every conformance class. A backend's test project subclasses each abstract suite class once,
/// supplying <see cref="CreateBackendAsync"/>, which must call its fixture's skip gate so the tests report Skipped
/// (not Failed) when the backend is unavailable.
/// </summary>
public abstract class ConformanceBase : IAsyncLifetime
{
/// <summary>Good.</summary>
protected const uint Good = ConformanceData.Good;
/// <summary>Bad (generic).</summary>
protected const uint Bad = ConformanceData.Bad;
/// <summary>BadNoCommunication.</summary>
protected const uint BadNoCommunication = ConformanceData.BadNoCommunication;
/// <summary>Uncertain (generic).</summary>
protected const uint Uncertain = ConformanceData.Uncertain;
/// <summary>The backend under test for the current test.</summary>
protected IConformanceBackend B { get; private set; } = null!;
/// <summary>Creates this test's isolated backend; calls the fixture's skip gate first.</summary>
protected abstract Task<IConformanceBackend> CreateBackendAsync();
/// <summary>T0 + <paramref name="s"/> seconds.</summary>
protected static DateTime At(double s) => ConformanceData.At(s);
/// <inheritdoc/>
public async ValueTask InitializeAsync() => B = await CreateBackendAsync();
/// <inheritdoc/>
public async ValueTask DisposeAsync()
{
if (B is not null) await B.DisposeAsync();
}
}