using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
///
/// A real, seeded SQLite database standing in for the SQL Server the driver polls — the offline substrate
/// for every reader test. It exists so the poll path is exercised against a genuine
/// : real parameter binding, real provider type coercion, real
/// , real "no such row".
/// Backed by a temporary FILE, not :memory:. The reader opens a fresh
/// connection per poll (Factory.CreateConnection() → set →
/// OpenAsync) and closes it afterwards. A plain in-memory SQLite database is destroyed the moment
/// its last connection closes, so poll #2 would silently find an empty schema — the seed would evaporate
/// between polls and every test would fail for a reason that has nothing to do with the code under test.
/// The alternative (a shared-cache in-memory database pinned by a keep-alive connection) works but adds an
/// invisible lifetime invariant: every consumer would have to keep the fixture alive for the database to
/// exist at all. A temp file has neither problem — it survives arbitrary open/close cycles, needs no
/// keep-alive, and its connection string is an ordinary path the code under test takes verbatim.
/// The fixture is a contract, not scaffolding. The seeded shapes below are what reader tests
/// assert against, so the constants are public and the seed deliberately covers the three cases that
/// behave differently: a present value, a present row whose value cell is NULL, and a key that is
/// absent entirely. Change a constant and you are changing what the reader is proven to do.
///
public sealed class SqlitePollFixture : IDisposable
{
// ---- key-value (EAV) source ----
/// The key-value table: TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT).
public const string KeyValueTable = "TagValues";
/// The key-value table's key column.
public const string KeyColumn = "tag_name";
/// The key-value table's value column.
public const string ValueColumn = "num_value";
/// The source-timestamp column carried by both seeded tables.
public const string TimestampColumn = "sample_ts";
/// A key whose row exists and whose value cell holds .
public const string PresentKey = "Line1.Speed";
/// The value seeded for .
public const double PresentValue = 42.0;
/// A second present key, so a group can legitimately fold more than one member.
public const string SecondPresentKey = "Line1.Pressure";
/// The value seeded for .
public const double SecondPresentValue = 3.5;
///
/// A key whose row exists but whose value cell is NULL — the case that must not be
/// confused with : the row was read, the value is simply not there.
///
public const string NullValueKey = "Line1.Temp";
///
/// A key with no row at all. Distinct from on purpose: a missing row
/// means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell.
///
public const string AbsentKey = "Line1.Missing";
// ---- wide-row source ----
///
/// The wide-row table:
/// LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT).
///
public const string WideRowTable = "LatestStatus";
/// The wide-row table's row-selector column.
public const string WideRowSelectorColumn = "station_id";
/// A station whose row exists, with both value columns populated.
public const string PresentStation = "7";
/// 's oven_temp.
public const double PresentStationOvenTemp = 180.5;
/// 's pressure.
public const double PresentStationPressure = 1.2;
///
/// The station holding the newest sample_ts — what a topByTimestamp row selector
/// must resolve to. Deliberately not the first-inserted row, so a plan that forgets the
/// ORDER BY … DESC (or the row limit) picks the wrong one and the test says so.
///
public const string NewestStation = "8";
/// 's oven_temp — the value a topByTimestamp plan yields.
public const double NewestStationOvenTemp = 210.25;
/// A station whose row exists but whose oven_temp cell is NULL.
public const string NullOvenTempStation = "9";
/// A station with no row at all.
public const string AbsentStation = "404";
private readonly string _databasePath;
/// Creates the temporary database, applies the schema, and seeds it.
public SqlitePollFixture()
{
_databasePath = Path.Combine(
Path.GetTempPath(), $"otopcua-sql-poll-{Guid.NewGuid():N}.db");
ConnectionString = new SqliteConnectionStringBuilder
{
DataSource = _databasePath,
}.ToString();
Connection = new SqliteConnection(ConnectionString);
Connection.Open();
Seed(Connection);
}
///
/// The connection string to hand the code under test. An ordinary file path — safe to open and close
/// any number of times, from any number of connections, in any order.
///
public string ConnectionString { get; }
///
/// The provider factory to hand the code under test, matching the reader's
/// (DbProviderFactory factory, string connectionString, …) seam.
/// This is the real , not a shim that hands back a
/// pre-opened connection: because the database is a file, the reader's genuine
/// create → open → query → close cycle works unmodified, so what the tests exercise is the production
/// connection lifecycle rather than a test-only shortcut around it.
///
public DbProviderFactory Factory => SqliteFactory.Instance;
///
/// A long-lived open connection over the same database, for arranging extra state or inspecting
/// results directly. Independent of the connections the code under test opens — closing one has no
/// effect on the others, and none of them destroy the data.
///
public SqliteConnection Connection { get; }
/// Opens a brand-new connection, exactly as the reader does on each poll.
/// An open connection the caller owns and must dispose.
public SqliteConnection OpenNewConnection()
{
var connection = new SqliteConnection(ConnectionString);
connection.Open();
return connection;
}
/// Runs one non-query statement against , for test-local arrangement.
/// The statement to execute.
public void Execute(string sql)
{
using var command = Connection.CreateCommand();
command.CommandText = sql;
command.ExecuteNonQuery();
}
/// Closes the fixture's connection, clears the pool, and deletes the temporary database file.
public void Dispose()
{
Connection.Close();
Connection.Dispose();
// Microsoft.Data.Sqlite pools connections per connection string; without this the file can still be
// held open and the delete silently fails, leaking a file per test class into the temp directory.
SqliteConnection.ClearAllPools();
try
{
if (File.Exists(_databasePath)) File.Delete(_databasePath);
}
catch (IOException)
{
// A leaked temp file must never fail a test run.
}
}
///
/// Applies the schema and rows described by this type's constants.
/// Every column is explicitly declared. SQLite is dynamically typed and would happily
/// store a string in an undeclared column, which would make the reader's type-coercion tests prove
/// nothing; declared types give pragma_table_info (and therefore
/// ) something real to report.
///
private static void Seed(SqliteConnection connection)
{
using var command = connection.CreateCommand();
command.CommandText = string.Create(CultureInfo.InvariantCulture, $"""
CREATE TABLE {KeyValueTable} (
{KeyColumn} TEXT NOT NULL,
{ValueColumn} REAL NULL,
{TimestampColumn} TEXT NOT NULL
);
INSERT INTO {KeyValueTable} ({KeyColumn}, {ValueColumn}, {TimestampColumn}) VALUES
('{PresentKey}', {PresentValue}, '2026-07-24T10:00:00Z'),
('{NullValueKey}', NULL, '2026-07-24T10:00:01Z'),
('{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02Z');
CREATE TABLE {WideRowTable} (
{WideRowSelectorColumn} INTEGER NOT NULL,
oven_temp REAL NULL,
pressure REAL NULL,
{TimestampColumn} TEXT NOT NULL
);
INSERT INTO {WideRowTable} ({WideRowSelectorColumn}, oven_temp, pressure, {TimestampColumn}) VALUES
({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00Z'),
({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01Z'),
({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02Z');
""");
command.ExecuteNonQuery();
}
}