diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index 033ff3d3..8b786bac 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -95,6 +95,7 @@ + diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs new file mode 100644 index 00000000..16c0ed61 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlPollServerFixture.cs @@ -0,0 +1,469 @@ +using System.Globalization; +using Microsoft.Data.SqlClient; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; + +/// +/// A real, seeded SQL Server database standing in for the source the Sql driver polls — +/// the live counterpart of the offline SqlitePollFixture, and the only place the shipped +/// Microsoft.Data.SqlClient path, the real INFORMATION_SCHEMA catalog SQL, and genuine +/// T-SQL column types are exercised at all. +/// Env-gated, and the gate is checked before any socket is touched. This fixture opens +/// nothing unless — or plus a +/// password — is set; absent, it records and every test calls +/// Assert.Skip. That is what keeps dotnet test genuinely green (and instant) on the +/// macOS dev box this is usually run from, which is the same contract every other +/// *.IntegrationTests fixture in this tree honours. +/// Safety against the shared central server (design §9). The always-on SQL Server on the +/// docker host hosts ConfigDb for the whole dev rig, so this fixture is deliberately +/// conservative: +/// +/// It uses its own database, — the supplied connection +/// string's catalog is used only to reach master and is otherwise ignored, so a +/// mis-set env var cannot aim the seed at somebody else's schema. +/// A supplied catalog literally named ConfigDb is rejected loudly, at +/// construction, rather than quietly retargeted — it is the signature of a copied-and-pasted +/// connection string, and the operator should know their env var is wrong. +/// It creates the database only if missing and never drops a database, so +/// re-running the suite is safe and an operator-created SqlPollFixture is never destroyed. +/// Only the objects inside its own schema are dropped and recreated, which +/// is what makes the seed deterministic across runs. +/// +/// The seed is a contract, not scaffolding. The constants below mirror +/// SqlitePollFixture's shape — a present value, a present row with a NULL cell, an +/// absent key, and a wide-row table whose newest row is deliberately not its first — so the +/// same assertions can be made offline and live, and a divergence between the SQLite and SQL Server +/// paths shows up as a failing assertion rather than as two suites that quietly test different +/// things. has no offline counterpart: it exists to pin +/// SqlServerDialect.MapColumnType against the type names SQL Server actually reports, +/// which a hand-written mapping table cannot do. +/// +public sealed class SqlPollServerFixture : IAsyncLifetime +{ + /// A full ADO.NET connection string for the fixture server. Takes precedence when set. + public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture"; + + /// + /// Server endpoint in host,port form (e.g. 10.100.0.35,14330) — the lighter-weight + /// gate. Requires ; defaults to sa. + /// + public const string EndpointEnvVar = "SQL_TEST_ENDPOINT"; + + /// SQL login for the form. Defaults to sa. + public const string UserEnvVar = "SQL_TEST_USER"; + + /// SQL password for the form. Required by that form. + public const string PasswordEnvVar = "SQL_TEST_PASSWORD"; + + /// The database this fixture creates and seeds. Never ConfigDb, never dropped. + public const string FixtureDatabase = "SqlPollFixture"; + + /// + /// The schema every seeded object lives in. Deliberately not dbo: it scopes the + /// drop-and-recreate to objects this fixture owns, and it makes every authored table a + /// two-part name, so the planner's QuoteQualifiedName ([sqlpoll].[TagValues]) is + /// exercised against a real server rather than only in a unit test. + /// + public const string Schema = "sqlpoll"; + + /// + /// The Application Name the driver's connections carry, so the pooling assertion can count + /// exactly this suite's sessions on a server shared with the whole dev rig. + /// + public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver"; + + /// The Application Name the fixture's own inspection connections carry. + private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture"; + + /// Seconds a connect attempt may take before the fixture calls the server unreachable. + private const int ProbeConnectTimeoutSeconds = 5; + + // ---- key-value (EAV) source ---- + + /// The key-value table: tag_name nvarchar(128), num_value float NULL, sample_ts datetime2. + 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 every seeded table. + 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. + public const string NullValueKey = "Line1.Temp"; + + /// A key with no row at all — a different outcome from . + public const string AbsentKey = "Line1.Missing"; + + /// The UTC source timestamp seeded for . + public static readonly DateTime PresentKeyTimestampUtc = + new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc); + + // ---- wide-row source ---- + + /// The wide-row table: station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2. + 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 selector + /// must resolve to. Deliberately not the first-inserted row, so a plan that loses its + /// ORDER BY … DESC or its SELECT TOP 1 picks the wrong one and the test says so. + /// + public const string NewestStation = "8"; + + /// 's oven_temp — what 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"; + + /// A view over — the TABLE_TYPE = 'VIEW' catalog row. + public const string WideRowView = "LatestStatusView"; + + // ---- T-SQL type-coverage source ---- + + /// + /// One wide row carrying one column per T-SQL family the dialect claims to map. Read with no + /// authored type, each column's driver type is inferred from what + /// SqlDataReader.GetDataTypeName reports — which is the only way to find out whether + /// SqlServerDialect.MapColumnType's table matches reality rather than matching itself. + /// + public const string TypeProbeTable = "TypeProbe"; + + /// The single-row selector column of . + public const string TypeProbeSelectorColumn = "probe_id"; + + /// The seeded row's value. + public const string TypeProbeRow = "1"; + + /// Seeded bit value. + public const bool ProbeBit = true; + + /// Seeded int value — int.MaxValue, so a narrowing coercion overflows loudly. + public const int ProbeInt = int.MaxValue; + + /// Seeded bigint value — beyond exact double range, so an Int64→Float64 slip shows. + public const long ProbeBigInt = 9007199254740993L; + + /// Seeded real value; exactly representable, so equality is a fair assertion. + public const float ProbeReal = 1.5f; + + /// Seeded float value; exactly representable. + public const double ProbeFloat = 3.25d; + + /// Seeded decimal(18,4) value — the documented Float64 precision collapse. + public const decimal ProbeDecimal = 123.4567m; + + /// Seeded nvarchar(64) value. + public const string ProbeNVarChar = "hello"; + + /// Seeded datetime2(3) value, read back as UTC. + public static readonly DateTime ProbeDateTimeUtc = + new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc); + + /// + /// Why the suite is skipping, or when the fixture is seeded and usable. + /// Tests read this and call Assert.Skip — the house idiom + /// (Snap7ServerFixture / ModbusSimulatorFixture). + /// + public string? SkipReason { get; private set; } + + /// + /// The connection string to hand the code under test: the seeded , + /// tagged with . Empty while is set. + /// + public string ConnectionString { get; private set; } = string.Empty; + + /// The server the fixture resolved, for skip/diagnostic messages. Never carries credentials. + public string Server { get; private set; } = string.Empty; + + /// Connection string the fixture's own inspection connections use. Empty while skipping. + private string _inspectionConnectionString = string.Empty; + + /// + /// Resolves the gate, creates if missing, and (re)seeds this + /// fixture's schema. + /// The two failure modes are handled deliberately differently: failing to connect is + /// the offline case and becomes a , while failing after the connection + /// opened (DDL, seeding) is a real fault against a reachable server and is allowed to throw — + /// swallowing that would leave the suite silently testing an unseeded database. + /// + /// A task that represents the asynchronous operation. + public async ValueTask InitializeAsync() + { + if (ResolveConnectionString() is not { } supplied) + { + SkipReason = + $"Set {ConnectionStringEnvVar} (a full connection string) — or {EndpointEnvVar} " + + $"(host,port) plus {PasswordEnvVar} (and optionally {UserEnvVar}, default 'sa') — to run " + + "the Sql driver's live SQL Server suite. No connection is attempted without it."; + return; + } + + var builder = new SqlConnectionStringBuilder(supplied); + GuardAgainstConfigDb(builder); + if (!builder.ContainsKey("Connect Timeout")) builder.ConnectTimeout = ProbeConnectTimeoutSeconds; + Server = builder.DataSource; + + // The supplied catalog is used ONLY to reach master; the seed always lands in FixtureDatabase. + var master = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = "master", + ApplicationName = FixtureApplicationName, + }.ConnectionString; + + SqlConnection connection; + try + { + connection = new SqlConnection(master); + await connection.OpenAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + SkipReason = + $"SQL Server at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " + + $"({ex.GetType().Name}: {ex.Message}). Check the endpoint and credentials in " + + $"{ConnectionStringEnvVar}/{EndpointEnvVar}, or leave them unset to skip this suite."; + return; + } + + await using (connection.ConfigureAwait(false)) + { + // Create-if-missing. This fixture never drops a database — an existing SqlPollFixture, whoever + // made it, keeps everything outside this fixture's own schema. + await ExecuteAsync( + connection, + $"IF DB_ID(N'{FixtureDatabase}') IS NULL CREATE DATABASE [{FixtureDatabase}];") + .ConfigureAwait(false); + } + + ConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = FixtureDatabase, + ApplicationName = DriverApplicationName, + }.ConnectionString; + + _inspectionConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString) + { + InitialCatalog = FixtureDatabase, + ApplicationName = FixtureApplicationName, + }.ConnectionString; + + await SeedAsync().ConfigureAwait(false); + } + + /// Holds no long-lived connection, so there is nothing to tear down. + /// A completed task. + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + + /// + /// Opens a fresh connection to the seeded database for direct arrangement or inspection, tagged + /// with the fixture's own application name so it is never counted by the pooling assertion. + /// + /// An open connection the caller owns and must dispose. + public async Task OpenInspectionConnectionAsync() + { + var connection = new SqlConnection(_inspectionConnectionString); + await connection.OpenAsync().ConfigureAwait(false); + return connection; + } + + /// Two-part name of a seeded object, in the form an authored tag's table carries. + /// The bare table or view name. + /// The schema.table name. + public static string Qualified(string table) => $"{Schema}.{table}"; + + /// + /// Reads the connection-string gate. Returns — touching no socket — when + /// neither form is configured. + /// + private static string? ResolveConnectionString() + { + if (Environment.GetEnvironmentVariable(ConnectionStringEnvVar) is { Length: > 0 } full) + return full; + + if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint) + return null; + + // A password is required rather than defaulted: guessing one would turn a misconfiguration into a + // failed login against a shared server, which is how accounts get locked out. + if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password) + return null; + + var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa"; + return new SqlConnectionStringBuilder + { + DataSource = endpoint, + InitialCatalog = FixtureDatabase, + UserID = user, + Password = password, + TrustServerCertificate = true, + Encrypt = false, + }.ConnectionString; + } + + /// + /// Refuses, loudly and before any I/O, a connection string aimed at the rig's shared + /// ConfigDb. The catalog is ignored either way (everything lands in + /// ), so this exists purely to tell an operator their env var is + /// wrong rather than to prevent damage that could otherwise occur. + /// + /// The supplied catalog is named ConfigDb. + private static void GuardAgainstConfigDb(SqlConnectionStringBuilder builder) + { + if (!string.Equals(builder.InitialCatalog, "ConfigDb", StringComparison.OrdinalIgnoreCase)) return; + + throw new InvalidOperationException( + $"{ConnectionStringEnvVar} names the database 'ConfigDb'. That is the dev rig's shared " + + $"configuration database; this fixture seeds its own '{FixtureDatabase}' database and must " + + "never be pointed at ConfigDb. Point the connection string at 'master' (or omit the catalog)."); + } + + /// + /// Drops and recreates this fixture's schema and every object in it, then inserts the seed rows. + /// Drop-and-recreate rather than merge-or-insert because the seed is an assertion contract: + /// a leftover row from an older revision of this file would make a passing test meaningless. The + /// blast radius is bounded to the schema, which nothing else creates. + /// + private async Task SeedAsync() + { + var connection = await OpenInspectionConnectionAsync().ConfigureAwait(false); + await using (connection.ConfigureAwait(false)) + { + // Views first: a view over a dropped table blocks nothing, but dropping the table under a live + // view leaves the catalog reporting a column set that no longer resolves. + foreach (var statement in SeedStatements()) + await ExecuteAsync(connection, statement).ConfigureAwait(false); + } + } + + /// + /// The seed, one batch per statement. CREATE SCHEMA and CREATE VIEW must each be the + /// first statement of their batch in T-SQL, which is why this is a list rather than one script. + /// Every numeric literal is composed under : plain + /// interpolation would emit 180,5 on a comma-decimal machine, and T-SQL would read that as + /// two column values rather than as one wrong number — a seed failure with a baffling message. + /// + private static IEnumerable SeedStatements() + { + yield return $"DROP VIEW IF EXISTS [{Schema}].[{WideRowView}];"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{WideRowTable}];"; + yield return $"DROP TABLE IF EXISTS [{Schema}].[{TypeProbeTable}];"; + yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');"; + + yield return $""" + CREATE TABLE [{Schema}].[{KeyValueTable}] ( + [{KeyColumn}] nvarchar(128) NOT NULL, + [{ValueColumn}] float NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}]) + VALUES (N'{PresentKey}', {PresentValue}, '2026-07-24T10:00:00'), + (N'{NullValueKey}', NULL, '2026-07-24T10:00:01'), + (N'{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02'); + """); + + yield return $""" + CREATE TABLE [{Schema}].[{WideRowTable}] ( + [{WideRowSelectorColumn}] int NOT NULL, + [oven_temp] float NULL, + [pressure] float NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + // NewestStation is inserted LAST but is also the newest timestamp; PresentStation is first. A + // topByTimestamp plan that lost its ORDER BY would still pick a plausible-looking row, so the two + // orderings are kept deliberately different. + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{WideRowTable}] + ([{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}]) + VALUES ({PresentStation}, {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00'), + ({NullOvenTempStation}, NULL, 1.6, '2026-07-24T10:00:01'), + ({NewestStation}, {NewestStationOvenTemp}, 1.4, '2026-07-24T10:00:02'); + """); + + yield return $""" + CREATE VIEW [{Schema}].[{WideRowView}] AS + SELECT [{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}] + FROM [{Schema}].[{WideRowTable}]; + """; + + yield return $""" + CREATE TABLE [{Schema}].[{TypeProbeTable}] ( + [{TypeProbeSelectorColumn}] int NOT NULL, + [bit_col] bit NULL, + [int_col] int NULL, + [bigint_col] bigint NULL, + [real_col] real NULL, + [float_col] float NULL, + [decimal_col] decimal(18,4) NULL, + [nvarchar_col] nvarchar(64) NULL, + [datetime2_col] datetime2(3) NULL, + [{TimestampColumn}] datetime2(3) NOT NULL + ); + """; + yield return string.Create(CultureInfo.InvariantCulture, $""" + INSERT INTO [{Schema}].[{TypeProbeTable}] + ([{TypeProbeSelectorColumn}], [bit_col], [int_col], [bigint_col], [real_col], [float_col], + [decimal_col], [nvarchar_col], [datetime2_col], [{TimestampColumn}]) + VALUES ({TypeProbeRow}, 1, {ProbeInt}, {ProbeBigInt}, {ProbeReal}, {ProbeFloat}, + {ProbeDecimal}, N'{ProbeNVarChar}', '2026-07-24T10:00:03', '2026-07-24T10:00:03'); + """); + } + + /// Runs one non-query batch. + private static async Task ExecuteAsync(SqlConnection connection, string sql) + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = sql; + await command.ExecuteNonQueryAsync().ConfigureAwait(false); + } + } +} + +/// +/// Collection definition so the seed runs once per test session rather than once per test class — +/// the same shape as Snap7ServerCollection. +/// +[CollectionDefinition(Name)] +public sealed class SqlPollServerCollection : ICollectionFixture +{ + /// The collection name test classes reference. + public const string Name = "SqlPollServer"; +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs new file mode 100644 index 00000000..c258ff6b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/SqlServerReadTests.cs @@ -0,0 +1,663 @@ +using System.Data; +using System.Data.Common; +using System.Globalization; +using System.Text.Json.Nodes; +using Microsoft.Data.SqlClient; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests; + +/// +/// The Sql driver against a real SQL Server, over the Microsoft.Data.SqlClient +/// provider it actually ships. Everything else in this driver's test estate runs on SQLite, which +/// means three things have never been exercised anywhere until this suite: the shipped ADO.NET +/// provider, T-SQL's own syntax (bracket-quoted two-part names, SELECT TOP 1), and the +/// INFORMATION_SCHEMA catalog SQL that SqlServerDialect carries but that no offline test +/// can reach — the SQLite dialect answers those questions with pragma_table_info instead. +/// Env-gated; skipping is the normal outcome. See for +/// the gate. With it unset nothing here opens a socket, so the suite is instant and green offline — +/// which is the point: a live test that goes red on a laptop teaches people to ignore red. +/// Out of scope, deliberately: the frozen-database / BadTimeout gate. That needs a +/// docker pause-able container this suite must never own, because the server it points at is +/// the rig's shared central SQL Server. It is a separate task with its own dedicated mssql +/// container. +/// +[Collection(SqlPollServerCollection.Name)] +public sealed class SqlServerReadTests +{ + private readonly SqlPollServerFixture _sql; + + /// Initializes a new instance of the class. + /// The seeded live-server fixture (collection-scoped). + public SqlServerReadTests(SqlPollServerFixture sql) => _sql = sql; + + // ---- lifecycle ---- + + /// + /// Initialize's liveness check (SELECT 1 over a real connection) succeeds and the driver + /// reports Healthy with the endpoint described credential-free. + /// + [Fact] + public async Task InitializeAsync_realSqlServer_reportsHealthyAndDescribesTheEndpoint() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); + + var health = driver.GetHealth(); + health.State.ShouldBe(DriverState.Healthy); + health.LastSuccessfulRead.ShouldNotBeNull(); + + // The endpoint description is what every log line and the Admin UI show. It must name the + // database and must not carry the password that reached the provider. + driver.Endpoint.ShouldContain(SqlPollServerFixture.FixtureDatabase); + driver.Endpoint.ShouldNotContain("Password"); + + driver.GetHostStatuses().ShouldHaveSingleItem().State.ShouldBe(HostState.Running); + } + + // ---- key-value model ---- + + /// The plan's headline case: a seeded key-value row round-trips as a Good snapshot. + [Fact] + public async Task ReadAsync_realSqlServer_roundTripsSeededKeyValue() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); + + var snapshots = await driver.ReadAsync(["Sql/Line1/Speed"], TestContext.Current.CancellationToken); + + var snapshot = snapshots.ShouldHaveSingleItem(); + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentValue); + // datetime2 comes back Unspecified; the reader stamps it UTC rather than reinterpreting it + // through the host's zone, which is the behaviour this asserts against a real column type. + snapshot.SourceTimestampUtc.ShouldBe(SqlPollServerFixture.PresentKeyTimestampUtc); + } + + /// + /// Two keys on the same table fold into ONE query (… WHERE [tag_name] IN (@k0, @k1)) and are + /// sliced back to the right tags. Against a real server this also proves the parameter binding and + /// the key-column stringification survive a genuine nvarchar key. + /// + [Fact] + public async Task ReadAsync_realSqlServer_foldsTwoKeysIntoOneGroupAndSlicesBackCorrectly() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), + KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey)); + + var snapshots = await driver.ReadAsync( + ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken); + + snapshots.Count.ShouldBe(2); + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentValue); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.SecondPresentValue); + } + + /// + /// A present row whose value cell is a real T-SQL NULL is Uncertain, not Bad and not + /// BadNoData — the row WAS read. + /// + [Fact] + public async Task ReadAsync_realSqlServer_nullValueCellIsUncertain() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey)); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Uncertain); + snapshot.Value.ShouldBeNull(); + // The row exists, so its timestamp is still readable — that is exactly what distinguishes this + // from the absent-key case below. + snapshot.SourceTimestampUtc.ShouldNotBeNull(); + } + + /// nullIsBad re-codes the same NULL cell as Bad, and only that cell. + [Fact] + public async Task ReadAsync_realSqlServer_nullValueCellIsBadWhenNullIsBadIsSet() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + nullIsBad: true, KeyValue("Sql/Line1/Temp", SqlPollServerFixture.NullValueKey)); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Temp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Bad); + } + + /// A key the table has no row for is BadNoData with no source timestamp. + [Fact] + public async Task ReadAsync_realSqlServer_absentKeyIsBadNoData() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Missing", SqlPollServerFixture.AbsentKey)); + + var snapshot = (await driver.ReadAsync(["Sql/Line1/Missing"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + snapshot.SourceTimestampUtc.ShouldBeNull(); + } + + // ---- wide-row model ---- + + /// + /// Two columns of one wide row, selected by a whereColumn/whereValue pair, come back from a + /// single query — including the bound selector value coercing from authored text to a real + /// int column server-side. + /// + [Fact] + public async Task ReadAsync_realSqlServer_wideRowSelectorReadsEveryColumnFromOneRow() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + WideRowWhere("Sql/Station7/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation), + WideRowWhere("Sql/Station7/Pressure", "pressure", SqlPollServerFixture.PresentStation), + WideRowWhere("Sql/Station404/OvenTemp", "oven_temp", SqlPollServerFixture.AbsentStation)); + + var snapshots = await driver.ReadAsync( + ["Sql/Station7/OvenTemp", "Sql/Station7/Pressure", "Sql/Station404/OvenTemp"], + TestContext.Current.CancellationToken); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[0].Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshots[1].Value).ShouldBe(SqlPollServerFixture.PresentStationPressure); + // A selector matching no row is BadNoData, not an error — and it does not disturb its siblings. + snapshots[2].StatusCode.ShouldBe(SqlStatusCodes.BadNoData); + } + + /// + /// The topByTimestamp selector resolves to the newest row. + /// This is the T-SQL-specific leg. The planner emits the row limit through the + /// dialect's SingleRowLimitPrefixSELECT TOP 1 … for T-SQL, where every offline + /// test exercises SQLite's trailing LIMIT 1 instead. The seed makes the newest row not the + /// first-inserted one, so losing either the TOP 1 or the ORDER BY … DESC yields a + /// different, wrong value rather than a coincidentally-right one. + /// + [Fact] + public async Task ReadAsync_realSqlServer_topByTimestampSelectsTheNewestRow() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(WideRowTop("Sql/Newest/OvenTemp", "oven_temp")); + + var snapshot = (await driver.ReadAsync(["Sql/Newest/OvenTemp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.NewestStationOvenTemp); + } + + /// A view reads exactly like the table it wraps — the shape operators are told to author. + [Fact] + public async Task ReadAsync_realSqlServer_readsThroughAView() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + WideRowWhere( + "Sql/View/OvenTemp", "oven_temp", SqlPollServerFixture.PresentStation, + table: SqlPollServerFixture.WideRowView)); + + var snapshot = (await driver.ReadAsync(["Sql/View/OvenTemp"], TestContext.Current.CancellationToken)) + .ShouldHaveSingleItem(); + + snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + Convert.ToDouble(snapshot.Value).ShouldBe(SqlPollServerFixture.PresentStationOvenTemp); + } + + // ---- type mapping against real T-SQL column types ---- + + /// + /// With no authored type, each tag's driver type is inferred from the provider's own + /// GetDataTypeName through SqlServerDialect.MapColumnType. This is the only test in + /// the estate where that map is checked against the strings SQL Server actually returns — + /// everywhere else it is fed a hand-written list, i.e. checked against itself. + /// + [Fact] + public async Task ReadAsync_realSqlServer_infersDriverTypesFromRealTsqlColumnTypes() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + TypeProbe("Sql/Probe/Bit", "bit_col"), + TypeProbe("Sql/Probe/Int", "int_col"), + TypeProbe("Sql/Probe/BigInt", "bigint_col"), + TypeProbe("Sql/Probe/Real", "real_col"), + TypeProbe("Sql/Probe/Float", "float_col"), + TypeProbe("Sql/Probe/Decimal", "decimal_col"), + TypeProbe("Sql/Probe/NVarChar", "nvarchar_col"), + TypeProbe("Sql/Probe/DateTime2", "datetime2_col")); + + var snapshots = await driver.ReadAsync( + [ + "Sql/Probe/Bit", "Sql/Probe/Int", "Sql/Probe/BigInt", "Sql/Probe/Real", + "Sql/Probe/Float", "Sql/Probe/Decimal", "Sql/Probe/NVarChar", "Sql/Probe/DateTime2", + ], + TestContext.Current.CancellationToken); + + foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + + // bit → Boolean, not "1"; the CLR type is what the address space declared for the node. + snapshots[0].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBit); + snapshots[1].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeInt); + // bigint stays Int64: the seeded value is past double's exact-integer range, so a slip to + // Float64 would come back as a different number rather than as a type-only difference. + snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeBigInt); + snapshots[3].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal); + snapshots[4].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeFloat); + // decimal collapses to Float64 — the documented v1 precision caveat, pinned here so a future + // change to that policy cannot land silently. + snapshots[5].Value.ShouldBeOfType() + .ShouldBe((double)SqlPollServerFixture.ProbeDecimal, tolerance: 1e-9); + snapshots[6].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeNVarChar); + snapshots[7].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeDateTimeUtc); + } + + /// + /// An authored type wins over the inferred column type, and the coercion runs over the + /// provider's real CLR cell rather than over a test double. + /// + [Fact] + public async Task ReadAsync_realSqlServer_declaredTypeOverridesTheInferredColumnType() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + TypeProbe("Sql/Probe/IntAsString", "int_col", DriverDataType.String), + TypeProbe("Sql/Probe/BitAsInt32", "bit_col", DriverDataType.Int32), + TypeProbe("Sql/Probe/RealAsFloat64", "real_col", DriverDataType.Float64)); + + var snapshots = await driver.ReadAsync( + ["Sql/Probe/IntAsString", "Sql/Probe/BitAsInt32", "Sql/Probe/RealAsFloat64"], + TestContext.Current.CancellationToken); + + foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + snapshots[0].Value.ShouldBeOfType() + .ShouldBe(SqlPollServerFixture.ProbeInt.ToString(CultureInfo.InvariantCulture)); + snapshots[1].Value.ShouldBeOfType().ShouldBe(1); + snapshots[2].Value.ShouldBeOfType().ShouldBe(SqlPollServerFixture.ProbeReal); + } + + /// + /// A cell the declared type cannot hold is BadTypeMismatch — Bad quality on that tag alone, + /// never a thrown read. int.MaxValue into an Int16 is a genuine provider-level + /// OverflowException, not a synthesised one. + /// + [Fact] + public async Task ReadAsync_realSqlServer_uncoercibleCellIsBadTypeMismatchAndSpares_itsSiblings() + { + SkipUnlessLive(); + + await using var driver = await StartAsync( + TypeProbe("Sql/Probe/IntAsInt16", "int_col", DriverDataType.Int16), + TypeProbe("Sql/Probe/Float", "float_col")); + + var snapshots = await driver.ReadAsync( + ["Sql/Probe/IntAsInt16", "Sql/Probe/Float"], TestContext.Current.CancellationToken); + + snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadTypeMismatch); + snapshots[0].Value.ShouldBeNull(); + snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.Good); + } + + // ---- cancellation + connection lifecycle ---- + + /// + /// A cancelled poll propagates rather than publishing + /// Bad-quality snapshots: the engine asked the poll to stop and nobody is waiting for values. The + /// asymmetry with a deadline breach (which DOES publish BadTimeout) is deliberate, so it is pinned + /// here against the real provider's cancellation path. + /// + [Fact] + public async Task ReadAsync_realSqlServer_cancelledTokenPropagatesRatherThanBadCoding() + { + SkipUnlessLive(); + + await using var driver = await StartAsync(KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey)); + + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + // ThrowsAnyAsync, not ThrowsAsync: the provider may surface the derived TaskCanceledException, and + // which of the two arrives is not a contract this driver makes. + await Assert.ThrowsAnyAsync( + async () => await driver.ReadAsync(["Sql/Line1/Speed"], cts.Token)); + + // Cancellation is teardown, not a database verdict: health must be untouched. + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// The reader opens and disposes a connection per poll, which is only sustainable because ADO.NET + /// pools them. After many polls the server should still hold a small, non-zero number of + /// sessions for this driver's Application Name: non-zero proves the disposed connections + /// went back to a pool instead of being torn down, and small proves the poll loop is not leaking + /// one per pass. Counting per application name is what makes this safe on a server shared with the + /// whole dev rig. + /// + [Fact] + public async Task ReadAsync_realSqlServer_repeatedPollsReusePooledConnections() + { + SkipUnlessLive(); + + const int polls = 25; + await using var driver = await StartAsync( + KeyValue("Sql/Line1/Speed", SqlPollServerFixture.PresentKey), + KeyValue("Sql/Line1/Pressure", SqlPollServerFixture.SecondPresentKey)); + + for (var i = 0; i < polls; i++) + { + var snapshots = await driver.ReadAsync( + ["Sql/Line1/Speed", "Sql/Line1/Pressure"], TestContext.Current.CancellationToken); + foreach (var snapshot in snapshots) snapshot.StatusCode.ShouldBe(SqlStatusCodes.Good); + } + + var sessions = await CountDriverSessionsAsync(); + sessions.ShouldBeGreaterThan(0, "the disposed connections should still be pooled open server-side"); + sessions.ShouldBeLessThan(polls, "a poll loop that leaked a connection per pass would show ~25"); + } + + // ---- INFORMATION_SCHEMA catalog (the browse path's SQL) ---- + + /// The dialect's schema list — real INFORMATION_SCHEMA.TABLES — sees the seeded schema. + [Fact] + public async Task Catalog_listSchemasSql_returnsTheSeededSchema() + { + SkipUnlessLive(); + + var schemas = await QueryCatalogAsync( + new SqlServerDialect().ListSchemasSql, + _ => { }, + reader => reader.GetString(reader.GetOrdinal("TABLE_SCHEMA"))); + + schemas.ShouldContain(SqlPollServerFixture.Schema); + } + + /// + /// The dialect's table list returns the seeded relations for the bound schema, and flags the view + /// as VIEW — the TABLE_TYPE the browse session decorates its label from. + /// + [Fact] + public async Task Catalog_listTablesSql_returnsSeededTablesAndMarksTheView() + { + SkipUnlessLive(); + + var rows = await QueryCatalogAsync( + new SqlServerDialect().ListTablesSql, + command => Bind(command, "@schema", SqlPollServerFixture.Schema), + reader => ( + Name: reader.GetString(reader.GetOrdinal("TABLE_NAME")), + Type: reader.GetString(reader.GetOrdinal("TABLE_TYPE")))); + + var byName = rows.ToDictionary(r => r.Name, r => r.Type, StringComparer.Ordinal); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.KeyValueTable, "BASE TABLE"); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowTable, "BASE TABLE"); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.TypeProbeTable, "BASE TABLE"); + byName.ShouldContainKeyAndValue(SqlPollServerFixture.WideRowView, "VIEW"); + } + + /// + /// The dialect's column list returns the key-value table's columns in ordinal order (the + /// order the browse tree renders), with DATA_TYPE values the dialect's map recognises. + /// + [Fact] + public async Task Catalog_listColumnsSql_returnsSeededColumnsInOrdinalOrder() + { + SkipUnlessLive(); + + var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.KeyValueTable); + + columns.Select(c => c.Name).ToArray().ShouldBe(new[] + { + SqlPollServerFixture.KeyColumn, + SqlPollServerFixture.ValueColumn, + SqlPollServerFixture.TimestampColumn, + }); + + var dialect = new SqlServerDialect(); + dialect.MapColumnType(columns[0].DataType).ShouldBe(DriverDataType.String); // nvarchar + dialect.MapColumnType(columns[1].DataType).ShouldBe(DriverDataType.Float64); // float + dialect.MapColumnType(columns[2].DataType).ShouldBe(DriverDataType.DateTime); // datetime2 + + // IS_NULLABLE is the third projected column and the browse reads it; prove it is really there. + columns[0].IsNullable.ShouldBe("NO"); + columns[1].IsNullable.ShouldBe("YES"); + } + + /// + /// Every T-SQL family the type-probe table declares maps to the driver type + /// SqlServerDialect.MapColumnType promises — read from the catalog's own + /// DATA_TYPE strings, which is the exact input the browse side-panel feeds it. + /// + [Fact] + public async Task Catalog_listColumnsSql_typeProbeDataTypesMapAcrossTheTsqlFamilies() + { + SkipUnlessLive(); + + var columns = await ReadCatalogColumnsAsync(SqlPollServerFixture.TypeProbeTable); + var byName = columns.ToDictionary(c => c.Name, c => c.DataType, StringComparer.Ordinal); + var dialect = new SqlServerDialect(); + + dialect.MapColumnType(byName["bit_col"]).ShouldBe(DriverDataType.Boolean); + dialect.MapColumnType(byName["int_col"]).ShouldBe(DriverDataType.Int32); + dialect.MapColumnType(byName["bigint_col"]).ShouldBe(DriverDataType.Int64); + dialect.MapColumnType(byName["real_col"]).ShouldBe(DriverDataType.Float32); + dialect.MapColumnType(byName["float_col"]).ShouldBe(DriverDataType.Float64); + dialect.MapColumnType(byName["decimal_col"]).ShouldBe(DriverDataType.Float64); + dialect.MapColumnType(byName["nvarchar_col"]).ShouldBe(DriverDataType.String); + dialect.MapColumnType(byName["datetime2_col"]).ShouldBe(DriverDataType.DateTime); + } + + // ---- helpers ---- + + /// Skips the calling test when the live-server gate is not configured or not reachable. + private void SkipUnlessLive() + { + if (_sql.SkipReason is not null) Assert.Skip(_sql.SkipReason); + } + + /// + /// Builds and initializes a driver over the seeded database. + /// There is no ForProduction / ForTest hatch on + /// — its single public constructor takes the resolved connection string, the dialect and an + /// optional factory, and leaving the factory unset is precisely what makes this suite exercise + /// SqlClientFactory.Instance, the provider the driver ships. + /// + private Task StartAsync(params RawTagEntry[] tags) => StartAsync(nullIsBad: false, tags); + + /// + private async Task StartAsync(bool nullIsBad, params RawTagEntry[] tags) + { + var options = new SqlDriverOptions + { + RawTags = tags, + NullIsBad = nullIsBad, + CommandTimeout = TimeSpan.FromSeconds(10), + OperationTimeout = TimeSpan.FromSeconds(15), + }; + + var driver = new SqlDriver( + options, + driverInstanceId: "sql-integration", + dialect: new SqlServerDialect(), + connectionString: _sql.ConnectionString); + + try + { + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + } + catch + { + await driver.DisposeAsync(); + throw; + } + + return driver; + } + + /// A key-value tag over the seeded EAV table. + private static RawTagEntry KeyValue(string rawPath, string key) => Tag(rawPath, new JsonObject + { + ["driver"] = "Sql", + ["model"] = "KeyValue", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.KeyValueTable), + ["keyColumn"] = SqlPollServerFixture.KeyColumn, + ["keyValue"] = key, + ["valueColumn"] = SqlPollServerFixture.ValueColumn, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + }); + + /// A wide-row tag selected by a whereColumn/whereValue pair. + private static RawTagEntry WideRowWhere( + string rawPath, string column, string station, string? table = null) => Tag(rawPath, new JsonObject + { + ["driver"] = "Sql", + ["model"] = "WideRow", + ["table"] = SqlPollServerFixture.Qualified(table ?? SqlPollServerFixture.WideRowTable), + ["columnName"] = column, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + ["rowSelector"] = new JsonObject + { + ["whereColumn"] = SqlPollServerFixture.WideRowSelectorColumn, + ["whereValue"] = station, + }, + }); + + /// A wide-row tag selected by newest timestamp. + private static RawTagEntry WideRowTop(string rawPath, string column) => Tag(rawPath, new JsonObject + { + ["driver"] = "Sql", + ["model"] = "WideRow", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.WideRowTable), + ["columnName"] = column, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + ["rowSelector"] = new JsonObject + { + ["topByTimestamp"] = SqlPollServerFixture.TimestampColumn, + }, + }); + + /// A wide-row tag over the single-row type-probe table, optionally with a declared type. + private static RawTagEntry TypeProbe(string rawPath, string column, DriverDataType? declared = null) + { + var config = new JsonObject + { + ["driver"] = "Sql", + ["model"] = "WideRow", + ["table"] = SqlPollServerFixture.Qualified(SqlPollServerFixture.TypeProbeTable), + ["columnName"] = column, + ["timestampColumn"] = SqlPollServerFixture.TimestampColumn, + ["rowSelector"] = new JsonObject + { + ["whereColumn"] = SqlPollServerFixture.TypeProbeSelectorColumn, + ["whereValue"] = SqlPollServerFixture.TypeProbeRow, + }, + }; + + // Absent "type" ⇒ the driver infers from the result set's column metadata, which is the whole + // point of the inference test; present ⇒ it overrides. + if (declared is not null) config["type"] = declared.Value.ToString(); + return Tag(rawPath, config); + } + + /// + /// Wraps an authored TagConfig object as a raw tag entry. + /// Composed as a rather than as an interpolated string literal on + /// purpose: these blobs are brace-dense and nested, and a hand-written literal that loses a brace + /// produces a config the parser silently rejects — which surfaces as BadNodeIdUnknown, i.e. + /// as a plausible-looking test failure about the driver rather than about the test. + /// + private static RawTagEntry Tag(string rawPath, JsonObject config) => + new(rawPath, config.ToJsonString(), WriteIdempotent: false); + + /// Runs one of the dialect's catalog statements against the seeded database. + private async Task> QueryCatalogAsync( + string sql, Action bind, Func project) + { + var connection = await _sql.OpenInspectionConnectionAsync(); + await using (connection.ConfigureAwait(false)) + { + var command = connection.CreateCommand(); + await using (command.ConfigureAwait(false)) + { + command.CommandText = sql; + bind(command); + + var results = new List(); + var reader = await command.ExecuteReaderAsync(TestContext.Current.CancellationToken); + await using (reader.ConfigureAwait(false)) + { + while (await reader.ReadAsync(TestContext.Current.CancellationToken)) + results.Add(project(reader)); + } + + return results; + } + } + } + + /// Reads one seeded table's catalog columns through the dialect's ListColumnsSql. + private Task> ReadCatalogColumnsAsync(string table) => + QueryCatalogAsync( + new SqlServerDialect().ListColumnsSql, + command => + { + Bind(command, "@schema", SqlPollServerFixture.Schema); + Bind(command, "@table", table); + }, + reader => ( + Name: reader.GetString(reader.GetOrdinal("COLUMN_NAME")), + DataType: reader.GetString(reader.GetOrdinal("DATA_TYPE")), + IsNullable: reader.GetString(reader.GetOrdinal("IS_NULLABLE")))); + + /// + /// Counts the server-side sessions carrying the driver's application name. Skips — rather than + /// failing — when the configured login cannot read the DMV, since VIEW SERVER STATE is a + /// property of the credentials the operator supplied and not of the code under test. + /// + private async Task CountDriverSessionsAsync() + { + try + { + var counts = await QueryCatalogAsync( + "SELECT COUNT(*) AS n FROM sys.dm_exec_sessions WHERE program_name = @app", + command => Bind(command, "@app", SqlPollServerFixture.DriverApplicationName), + reader => reader.GetInt32(reader.GetOrdinal("n"))); + return counts[0]; + } + catch (SqlException ex) + { + Assert.Skip( + "The configured login cannot read sys.dm_exec_sessions (VIEW SERVER STATE), so connection " + + $"pooling cannot be observed from here: {ex.Message}"); + throw; // unreachable; Assert.Skip throws. + } + } + + /// Binds one string catalog parameter, exactly as the browse session does. + private static void Bind(DbCommand command, string name, string value) + { + var parameter = command.CreateParameter(); + parameter.ParameterName = name; + parameter.DbType = DbType.String; + parameter.Value = value; + command.Parameters.Add(parameter); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj new file mode 100644 index 00000000..2a1e1c4c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests.csproj @@ -0,0 +1,39 @@ + + + + + $(NoWarn);OTOPCUA0001 + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + +