using System.Data;
using System.Data.Common;
using System.Diagnostics;
using System.Globalization;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
///
/// Proves against a real database (): the
/// N-in/N-out ordering contract, the slice-back of one result set to many tags, the three distinct
/// no-value outcomes (absent row / NULL cell / unresolvable ref), and — the part that cannot be proven
/// by reading the code — that a query which refuses to return does not wedge the caller.
/// Each test owns its own fixture. The fixture is a temp file, so a fresh one per test is
/// cheap and buys total isolation — which matters here because two tests deliberately mutate the
/// database (an extra duplicate row; an EXCLUSIVE transaction) in ways a shared fixture would leak into
/// every other test in the class.
///
public sealed class SqlPollReaderTests
{
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15);
// ---- the plan's headline contract: N in, N out, in input order ----
[Fact]
public async Task ReadAsync_returnsSnapshotsInInputOrder_absentKeyIsBadNoData()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("Speed", SqlitePollFixture.PresentKey),
KvTag("Missing", SqlitePollFixture.AbsentKey),
KvTag("Temp", SqlitePollFixture.NullValueKey));
var snapshots = await reader.ReadAsync(["Speed", "Missing", "Temp"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
// [0] present row, present cell — REAL infers Float64, so the published CLR type is double.
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
snapshots[0].SourceTimestampUtc.ShouldBe(
new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc)); // from sample_ts, not the poll clock
// [1] no row at all — NOT the same thing as a NULL cell.
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
snapshots[1].Value.ShouldBeNull();
// [2] row present, value cell NULL, nullIsBad = false ⇒ Uncertain (and the row's timestamp survives).
snapshots[2].Value.ShouldBeNull();
SqlStatusCodes.IsUncertain(snapshots[2].StatusCode).ShouldBeTrue();
snapshots[2].SourceTimestampUtc.ShouldBe(
new DateTime(2026, 7, 24, 10, 0, 1, DateTimeKind.Utc));
}
[Fact]
public async Task ReadAsync_withNullIsBad_publishesBadForANullCell_butStillBadNoDataForAnAbsentRow()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture, nullIsBad: true,
tags: [KvTag("Temp", SqlitePollFixture.NullValueKey), KvTag("Missing", SqlitePollFixture.AbsentKey)]);
var snapshots = await reader.ReadAsync(["Temp", "Missing"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Bad);
SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
// The nullIsBad switch governs a NULL cell only — an absent row keeps its own, more specific code.
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
}
[Fact]
public async Task ReadAsync_anUnresolvableRef_isBadNodeIdUnknown_andItsNeighboursStillRead()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("Speed", SqlitePollFixture.PresentKey),
KvTag("Pressure", SqlitePollFixture.SecondPresentKey));
var snapshots = await reader.ReadAsync(
["Speed", "NotAuthored", "Pressure"], CancellationToken.None);
snapshots.Count.ShouldBe(3);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
// The hole must not shift the tail — this is the ordering contract's real failure mode.
snapshots[2].Value.ShouldBe(SqlitePollFixture.SecondPresentValue);
}
[Fact]
public async Task ReadAsync_twoTagsSharingOneKeyValue_bothReceiveTheValue()
{
// SqlQueryPlan.Members keeps duplicates: these two tags bind ONE parameter but stay TWO members,
// and a reader that indexed members by key into a 1:1 map would feed only one of them.
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("SpeedA", SqlitePollFixture.PresentKey),
KvTag("SpeedB", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["SpeedA", "SpeedB"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good);
}
[Fact]
public async Task ReadAsync_theSameRefTwice_feedsBothSlots()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["Speed", "Speed"], CancellationToken.None);
snapshots.Count.ShouldBe(2);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
}
[Fact]
public async Task ReadAsync_withNoRefs_returnsAnEmptyList_andOpensNoConnection()
{
using var fixture = new SqlitePollFixture();
var factory = new TrackingFactory();
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false, resolve: _ => null);
(await reader.ReadAsync([], CancellationToken.None)).ShouldBeEmpty();
factory.Created.ShouldBe(0);
}
// ---- wide row ----
[Fact]
public async Task ReadAsync_wideRow_slicesEachColumnToItsOwnMember()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
WideTag("Pressure", "pressure", selectorValue: SqlitePollFixture.PresentStation));
var snapshots = await reader.ReadAsync(["Oven", "Pressure"], CancellationToken.None);
// One row, two tags — the wrong-slice defect would cross these two values over.
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentStationPressure);
snapshots[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc));
}
[Fact]
public async Task ReadAsync_wideRow_absentRowIsBadNoData_andANullCellIsUncertain()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
WideTag("Gone", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation),
WideTag("NullOven", "oven_temp", selectorValue: SqlitePollFixture.NullOvenTempStation),
WideTag("NullOvenPressure", "pressure", selectorValue: SqlitePollFixture.NullOvenTempStation));
var snapshots = await reader.ReadAsync(
["Gone", "NullOven", "NullOvenPressure"], CancellationToken.None);
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); // no row matched the selector
SqlStatusCodes.IsUncertain(snapshots[1].StatusCode).ShouldBeTrue(); // row matched, cell NULL
snapshots[1].Value.ShouldBeNull();
snapshots[2].Value.ShouldBe(1.6); // its neighbour on the SAME row is unaffected
}
[Fact]
public async Task ReadAsync_wideRow_topByTimestamp_readsTheNewestRow()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn));
var snapshots = await reader.ReadAsync(["Newest"], CancellationToken.None);
// Station 8, not the first-inserted station 7 — a lost ORDER BY / row limit shows up here.
snapshots[0].Value.ShouldBe(SqlitePollFixture.NewestStationOvenTemp);
snapshots[0].Value.ShouldNotBe(SqlitePollFixture.PresentStationOvenTemp);
}
[Fact]
public async Task ReadAsync_wideRow_whenTheSelectorMatchesManyRows_warnsInsteadOfSilentlyPickingOne()
{
using var fixture = new SqlitePollFixture();
// A second row for the SAME station. The where-pair form emits no ORDER BY and no row limit, so
// which of the two the reader publishes is decided by physical storage order — an authoring mistake
// (a selector column that is not actually unique) that must not pass silently. This is the WideRow
// counterpart of ReadAsync_whenASourceViolatesOneRowPerKey_...'s duplicate-key warning.
AddSecondRowForPresentStation(fixture);
var logger = new RecordingLogger();
var reader = Reader(fixture, logger,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
var snapshots = await reader.ReadAsync(["Oven"], CancellationToken.None);
// Still a value — the reader degrades to "last row wins", exactly as the key-value model does.
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good);
var warning = logger.Entries.ShouldHaveSingleItem();
warning.Level.ShouldBe(LogLevel.Warning);
// Names the table AND the selector, so an operator can find the offending tag from the log alone.
warning.Message.ShouldContain(SqlitePollFixture.WideRowTable);
warning.Message.ShouldContain(SqlitePollFixture.WideRowSelectorColumn);
warning.Message.ShouldContain(SqlitePollFixture.PresentStation);
}
[Fact]
public async Task ReadAsync_wideRow_theAmbiguousSelectorWarningIsRateLimited_andSilentWhenUnambiguous()
{
using var fixture = new SqlitePollFixture();
var logger = new RecordingLogger();
// A selector that matches exactly one row is the normal case and must log nothing at all —
// without this leg a warning that always fires would still pass the test above.
var clean = Reader(fixture, logger,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
await clean.ReadAsync(["Oven"], CancellationToken.None);
logger.Entries.ShouldBeEmpty();
AddSecondRowForPresentStation(fixture);
var ambiguous = Reader(fixture, logger,
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation));
await ambiguous.ReadAsync(["Oven"], CancellationToken.None);
await ambiguous.ReadAsync(["Oven"], CancellationToken.None);
// A poll loop hits this every cycle; the rate limit is what stops a misconfigured table flooding
// the log. Two polls, one warning.
logger.Entries.Count.ShouldBe(1);
}
// ---- type + timestamp mapping ----
[Fact]
public async Task ReadAsync_coercesTheCellToTheTagsDeclaredType()
{
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture,
KvTag("AsInt", SqlitePollFixture.PresentKey, DriverDataType.Int32),
KvTag("AsText", SqlitePollFixture.PresentKey, DriverDataType.String),
KvTag("Inferred", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["AsInt", "AsText", "Inferred"], CancellationToken.None);
snapshots[0].Value.ShouldBe(42); // int, not double — the declared type wins
snapshots[1].Value.ShouldBe("42"); // string
snapshots[2].Value.ShouldBe(42.0); // no declaration ⇒ dialect-inferred from REAL
}
[Fact]
public async Task ReadAsync_withNoTimestampColumn_stampsThePollClock()
{
using var fixture = new SqlitePollFixture();
var before = DateTime.UtcNow;
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey, timestampColumn: null));
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
snapshots[0].SourceTimestampUtc.ShouldNotBeNull();
snapshots[0].SourceTimestampUtc!.Value.ShouldBeGreaterThanOrEqualTo(before.AddSeconds(-1));
snapshots[0].SourceTimestampUtc!.Value.ShouldBe(snapshots[0].ServerTimestampUtc);
}
[Fact]
public async Task ReadAsync_whenASourceViolatesOneRowPerKey_takesTheLastRowDeterministically()
{
using var fixture = new SqlitePollFixture();
fixture.Execute(
$"INSERT INTO {SqlitePollFixture.KeyValueTable} " +
$"({SqlitePollFixture.KeyColumn}, {SqlitePollFixture.ValueColumn}, {SqlitePollFixture.TimestampColumn}) " +
$"VALUES ('{SqlitePollFixture.PresentKey}', 99.0, '2026-07-24T10:00:09Z')");
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
// Design §3.6: the contract is one row per key; when a source breaks it the reader is at least
// deterministic — last occurrence in reader order — rather than silently arbitrary.
snapshots[0].Value.ShouldBe(99.0);
}
// ---- the frozen-peer contract ----
[Fact]
public async Task ReadAsync_whenTheQueryCannotComplete_surfacesBadTimeoutWithinTheDeadline()
{
using var fixture = new SqlitePollFixture();
// A held EXCLUSIVE transaction is this rig's frozen peer: the SELECT below cannot proceed and
// Microsoft.Data.Sqlite's busy-retry loop is fully SYNCHRONOUS — it neither returns nor honours a
// cancellation token until CommandTimeout expires. That is precisely the S7 R2-01 shape (an async
// API that ignores its deadline), so only a real wall-clock bound can end this call early.
using var locker = fixture.OpenNewConnection();
Execute(locker, "BEGIN EXCLUSIVE");
try
{
// Deliberately inverted against the authoring rule (operationTimeout > commandTimeout): the
// point is to prove the CLIENT-side bound fires while the server-side backstop would still wait.
var reader = new SqlPollReader(
fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
var clock = Stopwatch.StartNew();
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
clock.Stop();
snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
snapshots[0].Value.ShouldBeNull();
// Without the wall-clock bound this returns at CommandTimeout (30 s), not at 0.5 s.
clock.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
}
finally
{
Execute(locker, "ROLLBACK");
}
}
[Fact]
public async Task ReadAsync_aTimedOutGroupKeepsItsConcurrencySlotUntilTheQueryTrulyFinishes()
{
// The reason the concurrency slot is released by the WORK and not by the waiter: a timed-out group
// is still holding a connection, so handing its slot back at the deadline would let the next poll
// open another one — and a database that stays frozen would accumulate one connection per poll pass
// forever. This test is the only thing that distinguishes the two designs; the concurrency-cap test
// above never times a group out, and the BadTimeout test above never counts connections.
using var fixture = new SqlitePollFixture();
var factory = new TrackingFactory();
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
// commandTimeout deliberately far beyond the test's own horizon: the wedged query must stay
// wedged for as long as the lock is held, so nothing but the test releases the slot.
commandTimeout: TimeSpan.FromSeconds(30), operationTimeout: TimeSpan.FromMilliseconds(500),
maxConcurrentGroups: 1, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
var locker = fixture.OpenNewConnection();
Execute(locker, "BEGIN EXCLUSIVE"); // outside the try: a ROLLBACK with no open transaction throws
try
{
// Poll 1 wedges against the lock and times out, abandoning a still-running query.
var first = await reader.ReadAsync(["Speed"], CancellationToken.None);
first[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
factory.Created.ShouldBe(1);
factory.Live.ShouldBe(1); // the zombie still owns its connection
// (a) The slot is STILL held. maxConcurrentGroups is 1, so poll 2 cannot even reach the
// factory: it times out waiting on the gate. Created staying at 1 is the load-bearing
// assertion — release-from-the-waiter would make this 2.
var second = await reader.ReadAsync(["Speed"], CancellationToken.None);
second[0].StatusCode.ShouldBe(SqlStatusCodes.BadTimeout);
factory.Created.ShouldBe(1);
factory.Live.ShouldBe(1);
}
finally
{
Execute(locker, "ROLLBACK");
locker.Dispose();
}
// (b) With the lock gone the zombie's query completes (or faults on its own cancelled deadline),
// closes its connection, and only then hands the slot back.
(await WaitUntilAsync(() => factory.Live == 0, TimeSpan.FromSeconds(30)))
.ShouldBeTrue("the abandoned group never released its connection");
var third = await reader.ReadAsync(["Speed"], CancellationToken.None);
third[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
factory.Created.ShouldBe(2); // the slot was reusable exactly once the zombie finished
factory.Live.ShouldBe(0);
}
[Fact]
public async Task ReadAsync_whenTheCallerCancels_propagatesTheCancellation()
{
// Deliberate asymmetry: OUR deadline expiring is a data-quality outcome (BadTimeout snapshots, so
// clients see the staleness); the CALLER cancelling is the engine tearing the poll down, and must
// propagate rather than be laundered into a snapshot nobody will consume.
using var fixture = new SqlitePollFixture();
var reader = Reader(fixture, KvTag("Speed", SqlitePollFixture.PresentKey));
using var cancelled = new CancellationTokenSource();
await cancelled.CancelAsync();
await Should.ThrowAsync(
async () => await reader.ReadAsync(["Speed"], cancelled.Token));
}
[Fact]
public async Task ReadAsync_whenTheDatabaseCannotBeOpened_throwsSoTheEngineBacksOff()
{
// IReadable's contract: per-tag failures are Bad-coded snapshots, but an unreachable driver throws.
using var fixture = new SqlitePollFixture();
var unreachable = new SqliteConnectionStringBuilder
{
DataSource = Path.Combine(
Path.GetTempPath(), $"otopcua-sql-no-such-dir-{Guid.NewGuid():N}", "db.sqlite"),
}.ToString();
var reader = new SqlPollReader(
fixture.Factory, unreachable, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
await Should.ThrowAsync(
async () => await reader.ReadAsync(["Speed"], CancellationToken.None));
}
// ---- connection lifecycle ----
[Theory]
[InlineData(1)]
[InlineData(3)]
public async Task ReadAsync_neverHoldsMoreConnectionsThanMaxConcurrentGroups(int maxConcurrentGroups)
{
using var fixture = new SqlitePollFixture();
// Three distinct group keys — key-value, wide-row where-pair, wide-row topByTimestamp.
var tags = new[]
{
KvTag("Speed", SqlitePollFixture.PresentKey),
WideTag("Oven", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
WideTag("Newest", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn),
};
// The delay makes the groups genuinely overlap; without it a SQLite query is too fast for
// concurrency to be observable at all and the cap assertion below would pass vacuously.
var factory = new TrackingFactory(TimeSpan.FromMilliseconds(150));
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: TimeSpan.FromSeconds(30),
maxConcurrentGroups: maxConcurrentGroups, nullIsBad: false, resolve: Resolver(tags));
var snapshots = await reader.ReadAsync(["Speed", "Oven", "Newest"], CancellationToken.None);
snapshots.ShouldAllBe(s => s.StatusCode == SqlStatusCodes.Good);
factory.Created.ShouldBe(3);
factory.Peak.ShouldBe(maxConcurrentGroups); // == 3 for the uncapped run proves the overlap is real
factory.Live.ShouldBe(0); // every connection closed — no leak under the poll loop
}
[Fact]
public async Task ReadAsync_repeatedPolls_leakNoConnections()
{
using var fixture = new SqlitePollFixture();
var factory = new TrackingFactory();
var reader = new SqlPollReader(
factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false,
resolve: Resolver(KvTag("Speed", SqlitePollFixture.PresentKey)));
for (var poll = 0; poll < 5; poll++)
{
var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);
snapshots[0].Value.ShouldBe(SqlitePollFixture.PresentValue);
}
factory.Created.ShouldBe(5);
factory.Live.ShouldBe(0);
}
// ---- argument guards ----
[Fact]
public void Constructor_rejectsAnUnusableTimeoutOrConcurrencyCap()
{
var dialect = new SqliteDialect();
Should.Throw(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, TimeSpan.Zero, OperationTimeout, 4, false, _ => null));
Should.Throw(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, CommandTimeout, TimeSpan.Zero, 4, false, _ => null));
Should.Throw(() => new SqlPollReader(
dialect.Factory, "Data Source=x", dialect, CommandTimeout, OperationTimeout, 0, false, _ => null));
Should.Throw(() => new SqlPollReader(
dialect.Factory, " ", dialect, CommandTimeout, OperationTimeout, 4, false, _ => null));
}
[Fact]
public async Task ReadAsync_rejectsANullReferenceList()
=> await Should.ThrowAsync(async () =>
{
using var fixture = new SqlitePollFixture();
await Reader(fixture).ReadAsync(null!, CancellationToken.None);
});
// ---- helpers ----
private static SqlPollReader Reader(SqlitePollFixture fixture, params SqlTagDefinition[] tags)
=> Reader(fixture, nullIsBad: false, tags: tags);
private static SqlPollReader Reader(
SqlitePollFixture fixture, bool nullIsBad, params SqlTagDefinition[] tags)
=> new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: nullIsBad, resolve: Resolver(tags));
private static SqlPollReader Reader(
SqlitePollFixture fixture, ILogger logger, params SqlTagDefinition[] tags)
=> new(fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
maxConcurrentGroups: 4, nullIsBad: false, resolve: Resolver(tags), logger: logger);
///
/// Adds a second row for the station the wide-row tests
/// select, breaking that model's one-row-per-selector contract.
///
private static void AddSecondRowForPresentStation(SqlitePollFixture fixture)
=> fixture.Execute(string.Create(CultureInfo.InvariantCulture, $"""
INSERT INTO {SqlitePollFixture.WideRowTable}
({SqlitePollFixture.WideRowSelectorColumn}, oven_temp, pressure,
{SqlitePollFixture.TimestampColumn})
VALUES ({SqlitePollFixture.PresentStation}, 999.0, 9.9, '2026-07-24T10:00:09Z')
"""));
/// Runs one statement on a caller-owned connection.
private static void Execute(SqliteConnection connection, string sql)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
///
/// Polls until it holds or elapses. Bounded on
/// purpose: a test that asserts an abandoned task eventually finishes must fail, not hang, when it
/// does not.
///
private static async Task WaitUntilAsync(Func condition, TimeSpan limit)
{
var clock = Stopwatch.StartNew();
while (clock.Elapsed < limit)
{
if (condition()) return true;
await Task.Delay(25);
}
return condition();
}
/// The driver's RawPath→definition table, standing in for EquipmentTagRefResolver.
private static Func Resolver(params SqlTagDefinition[] tags)
{
var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal);
return rawPath => table.GetValueOrDefault(rawPath);
}
private static SqlTagDefinition KvTag(
string name,
string keyValue,
DriverDataType? declaredType = null,
string? timestampColumn = SqlitePollFixture.TimestampColumn)
=> new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
ValueColumn: SqlitePollFixture.ValueColumn,
TimestampColumn: timestampColumn,
DeclaredType: declaredType);
private static SqlTagDefinition WideTag(
string name,
string columnName,
string? selectorValue = null,
string? topByTimestamp = null,
string? timestampColumn = SqlitePollFixture.TimestampColumn)
=> new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable,
ColumnName: columnName,
TimestampColumn: timestampColumn,
RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn,
RowSelectorValue: selectorValue,
RowSelectorTopByTimestamp: topByTimestamp);
///
/// Captures the reader's log so "reported, not silent" can be asserted rather than assumed — the
/// contract-violation warnings are the reader's only signal that a source is misauthored, so they are
/// behaviour, not diagnostics.
///
private sealed class RecordingLogger : ILogger
{
/// Every record written, level + rendered message.
public List<(LogLevel Level, string Message)> Entries { get; } = [];
public IDisposable BeginScope(TState state) where TState : notnull => Scope.Instance;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log(
LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func formatter)
{
lock (Entries) Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class Scope : IDisposable
{
public static Scope Instance { get; } = new();
public void Dispose() { }
}
}
///
/// A over SQLite that counts how many connections the reader has
/// created and how many are alive at once — the only way to observe the connection lifecycle from
/// outside, since deliberately owns its connections end to end.
/// The optional createDelay widens the window each connection is alive so that
/// concurrent group execution is actually observable against a database whose queries would
/// otherwise finish in microseconds.
///
private sealed class TrackingFactory(TimeSpan createDelay = default) : DbProviderFactory
{
private readonly object _sync = new();
private int _live;
private int _peak;
private int _created;
/// How many connections the reader has asked the factory for.
public int Created { get { lock (_sync) { return _created; } } }
/// How many created connections have not yet closed.
public int Live { get { lock (_sync) { return _live; } } }
/// The high-water mark of .
public int Peak { get { lock (_sync) { return _peak; } } }
public override DbConnection CreateConnection()
{
var connection = SqliteFactory.Instance.CreateConnection()!;
connection.StateChange += OnStateChange;
lock (_sync)
{
_created++;
_live++;
if (_live > _peak) _peak = _live;
}
if (createDelay > TimeSpan.Zero) Thread.Sleep(createDelay);
return connection;
}
private void OnStateChange(object sender, StateChangeEventArgs e)
{
if (e.CurrentState != ConnectionState.Closed && e.CurrentState != ConnectionState.Broken) return;
lock (_sync) { _live--; }
}
}
}