56bb4855bb
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).
94 lines
4.7 KiB
C#
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 (<= 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();
|
|
}
|
|
}
|