a05cff794c
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
213 lines
10 KiB
C#
213 lines
10 KiB
C#
using System.Data.Common;
|
|
using System.Globalization;
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// <see cref="DbConnection"/>: real parameter binding, real provider type coercion, real
|
|
/// <see cref="DBNull"/>, real "no such row".
|
|
/// <para><b>Backed by a temporary FILE, not <c>:memory:</c>.</b> The reader opens a <em>fresh</em>
|
|
/// connection per poll (<c>Factory.CreateConnection()</c> → set <see cref="ConnectionString"/> →
|
|
/// <c>OpenAsync</c>) 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.</para>
|
|
/// <para><b>The fixture is a contract, not scaffolding.</b> 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 <b>NULL</b>, and a key that is
|
|
/// <b>absent</b> entirely. Change a constant and you are changing what the reader is proven to do.</para>
|
|
/// </summary>
|
|
public sealed class SqlitePollFixture : IDisposable
|
|
{
|
|
// ---- key-value (EAV) source ----
|
|
|
|
/// <summary>The key-value table: <c>TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)</c>.</summary>
|
|
public const string KeyValueTable = "TagValues";
|
|
|
|
/// <summary>The key-value table's key column.</summary>
|
|
public const string KeyColumn = "tag_name";
|
|
|
|
/// <summary>The key-value table's value column.</summary>
|
|
public const string ValueColumn = "num_value";
|
|
|
|
/// <summary>The source-timestamp column carried by both seeded tables.</summary>
|
|
public const string TimestampColumn = "sample_ts";
|
|
|
|
/// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
|
|
public const string PresentKey = "Line1.Speed";
|
|
|
|
/// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
|
|
public const double PresentValue = 42.0;
|
|
|
|
/// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
|
|
public const string SecondPresentKey = "Line1.Pressure";
|
|
|
|
/// <summary>The value seeded for <see cref="SecondPresentKey"/>.</summary>
|
|
public const double SecondPresentValue = 3.5;
|
|
|
|
/// <summary>
|
|
/// A key whose row <b>exists</b> but whose value cell is <c>NULL</c> — the case that must not be
|
|
/// confused with <see cref="AbsentKey"/>: the row was read, the value is simply not there.
|
|
/// </summary>
|
|
public const string NullValueKey = "Line1.Temp";
|
|
|
|
/// <summary>
|
|
/// A key with <b>no row at all</b>. Distinct from <see cref="NullValueKey"/> on purpose: a missing row
|
|
/// means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell.
|
|
/// </summary>
|
|
public const string AbsentKey = "Line1.Missing";
|
|
|
|
// ---- wide-row source ----
|
|
|
|
/// <summary>
|
|
/// The wide-row table:
|
|
/// <c>LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT)</c>.
|
|
/// </summary>
|
|
public const string WideRowTable = "LatestStatus";
|
|
|
|
/// <summary>The wide-row table's row-selector column.</summary>
|
|
public const string WideRowSelectorColumn = "station_id";
|
|
|
|
/// <summary>A station whose row exists, with both value columns populated.</summary>
|
|
public const string PresentStation = "7";
|
|
|
|
/// <summary><see cref="PresentStation"/>'s <c>oven_temp</c>.</summary>
|
|
public const double PresentStationOvenTemp = 180.5;
|
|
|
|
/// <summary><see cref="PresentStation"/>'s <c>pressure</c>.</summary>
|
|
public const double PresentStationPressure = 1.2;
|
|
|
|
/// <summary>
|
|
/// The station holding the <b>newest</b> <c>sample_ts</c> — what a <c>topByTimestamp</c> row selector
|
|
/// must resolve to. Deliberately not the first-inserted row, so a plan that forgets the
|
|
/// <c>ORDER BY … DESC</c> (or the row limit) picks the wrong one and the test says so.
|
|
/// </summary>
|
|
public const string NewestStation = "8";
|
|
|
|
/// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — the value a <c>topByTimestamp</c> plan yields.</summary>
|
|
public const double NewestStationOvenTemp = 210.25;
|
|
|
|
/// <summary>A station whose row exists but whose <c>oven_temp</c> cell is <c>NULL</c>.</summary>
|
|
public const string NullOvenTempStation = "9";
|
|
|
|
/// <summary>A station with no row at all.</summary>
|
|
public const string AbsentStation = "404";
|
|
|
|
private readonly string _databasePath;
|
|
|
|
/// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public string ConnectionString { get; }
|
|
|
|
/// <summary>
|
|
/// The provider factory to hand the code under test, matching the reader's
|
|
/// <c>(DbProviderFactory factory, string connectionString, …)</c> seam.
|
|
/// <para>This is the <em>real</em> <see cref="SqliteFactory"/>, 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.</para>
|
|
/// </summary>
|
|
public DbProviderFactory Factory => SqliteFactory.Instance;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public SqliteConnection Connection { get; }
|
|
|
|
/// <summary>Opens a brand-new connection, exactly as the reader does on each poll.</summary>
|
|
/// <returns>An open connection the caller owns and must dispose.</returns>
|
|
public SqliteConnection OpenNewConnection()
|
|
{
|
|
var connection = new SqliteConnection(ConnectionString);
|
|
connection.Open();
|
|
return connection;
|
|
}
|
|
|
|
/// <summary>Runs one non-query statement against <see cref="Connection"/>, for test-local arrangement.</summary>
|
|
/// <param name="sql">The statement to execute.</param>
|
|
public void Execute(string sql)
|
|
{
|
|
using var command = Connection.CreateCommand();
|
|
command.CommandText = sql;
|
|
command.ExecuteNonQuery();
|
|
}
|
|
|
|
/// <summary>Closes the fixture's connection, clears the pool, and deletes the temporary database file.</summary>
|
|
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.
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies the schema and rows described by this type's constants.
|
|
/// <para>Every column is <b>explicitly declared</b>. 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 <c>pragma_table_info</c> (and therefore
|
|
/// <see cref="SqliteDialect.MapColumnType"/>) something real to report.</para>
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|