fix(historian): SQL quality contract, long-name refusal, info-bit masking, AtTime-after-now, bootstrap hardening
- I-1: an Empty sample is always Bad. The writer stores a non-Bad NaN/Inf as Empty+BadOutOfRange and any other non-Bad Empty as BadNoData; the updater refuses a non-Bad Empty with BadInvalidArgument; reads clamp a legacy non-Bad Empty row to BadNoData (Raw and AtTime). - Tag/source names over 400 chars are refused, never truncated: writer per-tag failure, updater BadInvalidArgument, reads not-found, events none. - HistorianInfoBits masks the historian bits before setting one. - AtTime past now, stepped from the last sample => UncertainDataSubNormal. - Bootstrapper: logger, 5 s negative cache, ClearPool + Warning when sp_releaseapplock fails. - Provider builds its shared state lazily (an invalid Schema no longer throws at Resolve); Kinds is a FrozenSet; Gateway reuses the recorder's DefaultSupportedKinds. - Cleanup: SqlHistorianDataTypes in its own file (updater dup removed), dead Invalidate and unused status consts deleted, named severity masks, alarm writer brace. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+13
@@ -116,6 +116,19 @@ public sealed class AtTimeReadTests(SqlHistorianFixture fx)
|
||||
s.StatusCode.ShouldBe(GoodInterpolated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_time_after_now_stepped_from_the_last_sample_is_uncertain()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAAsync(); // last sample +40 = 50, Good
|
||||
var src = db.Source(new FixedClock(new DateTimeOffset(At(42))));
|
||||
var s = (await src.ReadAtTimeAsync("A", [At(41), At(45)], Ct)).Samples;
|
||||
s[0].Value.ShouldBe(50d);
|
||||
s[0].StatusCode.ShouldBe(GoodInterpolated); // after the last sample but not after now: held Good
|
||||
s[1].Value.ShouldBe(50d);
|
||||
s[1].StatusCode.ShouldBe(UncertainInterpolated); // after now: the value is not known to still hold
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_uncertain_neighbour_makes_the_interpolation_uncertain()
|
||||
{
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>A settable clock for the data source's "now" and the bootstrapper's failure cache.</summary>
|
||||
internal sealed class FixedClock(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Names longer than the 400-character <c>Tag.Name</c> / <c>Event.SourceName</c> columns are refused, never
|
||||
/// truncated. Each test seeds a series under the 400-character PREFIX of the long name: a sized
|
||||
/// <c>NVarChar(400)</c> parameter would silently cut the long name to that prefix and hit the wrong series.
|
||||
/// </summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class LongNameTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
private static DateTime At(double s) => ReadTestDb.At(s);
|
||||
|
||||
private const uint BadInvalidArgument = 0x80AB0000u;
|
||||
private const uint BadNoData = 0x809B0000u;
|
||||
|
||||
private static readonly string Prefix = new('p', 400);
|
||||
private static readonly string Long = Prefix + "-x";
|
||||
|
||||
[Fact]
|
||||
public async Task The_writer_refuses_a_long_name_as_a_per_tag_failure_and_writes_nothing()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good));
|
||||
var w = new SqlServerHistorianValueWriter(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
|
||||
(await w.WriteLiveValuesAsync(Long, [new(At(5), HistorianValue.FromDouble(2), 0u)], Ct)).ShouldBeFalse();
|
||||
|
||||
(await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Sample")}")).ShouldBe(1);
|
||||
(await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Tag")}")).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_updater_answers_BadInvalidArgument_for_every_item_of_a_long_name()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good));
|
||||
var u = new SqlServerHistorianDataUpdater(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
|
||||
(await u.UpdateDataAsync(Long, HistoryUpdateMode.Update,
|
||||
[new(At(0), HistorianValue.FromDouble(9), 0u), new(At(1), HistorianValue.FromDouble(9), 0u)], Ct))
|
||||
.ShouldBe([BadInvalidArgument, BadInvalidArgument]);
|
||||
(await u.DeleteRawAsync(Long, At(-1), At(10), Ct)).ShouldBe(BadInvalidArgument);
|
||||
(await u.DeleteAtTimeAsync(Long, [At(0)], Ct)).ShouldBe([BadInvalidArgument]);
|
||||
|
||||
// The prefix series is untouched: not replaced, not deleted.
|
||||
var raw = (await db.Source().ReadRawAsync(Prefix, At(-1), At(10), 0, Ct)).Samples;
|
||||
raw.Select(s => s.Value).ShouldBe([(object)1d]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reads_of_a_long_name_are_not_found_rather_than_the_prefix_series()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await db.SeedAsync(Prefix, (At(0), HistorianValue.FromDouble(1), ReadTestDb.Good));
|
||||
var src = db.Source();
|
||||
|
||||
(await src.ReadRawAsync(Long, At(-1), At(10), 0, Ct)).Samples.ShouldBeEmpty();
|
||||
(await src.ReadAtTimeAsync(Long, [At(0)], Ct)).Samples.Single().StatusCode.ShouldBe(BadNoData);
|
||||
(await src.ReadProcessedAsync(Long, At(-1), At(10), TimeSpan.Zero, HistoryAggregateType.Count, Ct))
|
||||
.Samples.Single().StatusCode.ShouldBe(HistorianInfoBits.WithCalculated(BadNoData));
|
||||
src.GetHealthSnapshot().TotalFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_event_read_filtered_by_a_long_source_matches_nothing()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
await using (var c = await db.Conn.OpenAsync(Ct))
|
||||
await using (var cmd = db.Conn.CreateCommand(c,
|
||||
$"INSERT {db.Conn.Q("Event")} (EventId, SourceName, AlarmId, AlarmName, AlarmTypeName, Severity, EventKind, Message, " +
|
||||
"UserName, Comment, EventTimeUtc, ReceivedTimeUtc) VALUES ('e1', @src, 'a', 'n', 'T', 500, 'Activated', NULL, NULL, NULL, @t, @t)"))
|
||||
{
|
||||
cmd.Parameters.AddWithValue("@src", Prefix);
|
||||
SqlHistorianConnection.AddDateTime2(cmd, "@t", At(0));
|
||||
await cmd.ExecuteNonQueryAsync(Ct);
|
||||
}
|
||||
|
||||
var src = db.Source();
|
||||
(await src.ReadEventsAsync(Prefix, At(-1), At(10), 0, Ct)).Events.Count.ShouldBe(1); // control
|
||||
(await src.ReadEventsAsync(Long, At(-1), At(10), 0, Ct)).Events.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Final-review I-1: a stored sample is never served with a better quality than "no value". An Empty (null-value)
|
||||
/// sample is always Bad — forced Bad by the value writer, refused non-Bad by the updater, and clamped to
|
||||
/// <c>BadNoData</c> on read for rows written before either rule existed.
|
||||
/// </summary>
|
||||
[Collection(SqlHistorianCollection.Name)]
|
||||
public sealed class QualityContractTests(SqlHistorianFixture fx)
|
||||
{
|
||||
private static readonly CancellationToken Ct = CancellationToken.None;
|
||||
private static DateTime At(double s) => ReadTestDb.At(s);
|
||||
|
||||
private const uint BadOutOfRange = 0x803C0000u;
|
||||
private const uint BadNoData = 0x809B0000u;
|
||||
private const uint BadInvalidArgument = 0x80AB0000u;
|
||||
private const uint BadNoCommunication = 0x80310000u;
|
||||
|
||||
private static bool IsBad(uint status) => (status & 0x80000000u) != 0;
|
||||
|
||||
private static SqlServerHistorianValueWriter Writer(ReadTestDb db) =>
|
||||
new(db.Conn, db.Boot, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
|
||||
private static SqlServerHistorianDataUpdater Updater(ReadTestDb db) =>
|
||||
new(db.Conn, new SqlServerTagCatalog(db.Conn, db.Boot));
|
||||
|
||||
[Fact]
|
||||
public async Task A_good_NaN_written_through_the_real_writer_reads_back_Bad_on_Raw_and_AtTime()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
(await Writer(db).WriteLiveValuesAsync("nan", [
|
||||
new(At(0), HistorianValue.FromDouble(10), 0u),
|
||||
new(At(10), HistorianValue.FromDouble(double.NaN), 0u),
|
||||
new(At(20), HistorianValue.FromDouble(double.PositiveInfinity), 0x40000000u),
|
||||
new(At(30), HistorianValue.FromDouble(double.NegativeInfinity), BadNoCommunication),
|
||||
], Ct)).ShouldBeTrue();
|
||||
|
||||
var raw = (await db.Source().ReadRawAsync("nan", At(0), At(100), 0, Ct)).Samples;
|
||||
raw.Count.ShouldBe(4);
|
||||
raw[1].Value.ShouldBeNull();
|
||||
raw[1].StatusCode.ShouldBe(BadOutOfRange); // Good NaN ⇒ stored Bad
|
||||
raw[2].StatusCode.ShouldBe(BadOutOfRange); // Uncertain +Inf ⇒ stored Bad
|
||||
raw[3].StatusCode.ShouldBe(BadNoCommunication); // an existing Bad code is kept
|
||||
|
||||
var at = (await db.Source().ReadAtTimeAsync("nan", [At(10), At(15)], Ct)).Samples;
|
||||
at.ShouldAllBe(s => s.Value == null);
|
||||
at.ShouldAllBe(s => IsBad(s.StatusCode));
|
||||
at[1].StatusCode.ShouldBe(BadOutOfRange); // inside the "outage" the NaN opened
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_non_bad_Empty_written_through_the_real_writer_is_stored_BadNoData()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
(await Writer(db).WriteLiveValuesAsync("empty", [
|
||||
new(At(0), HistorianValue.Empty, 0u),
|
||||
new(At(1), HistorianValue.Empty, 0x40000000u),
|
||||
new(At(2), HistorianValue.Empty, BadNoCommunication),
|
||||
], Ct)).ShouldBeTrue();
|
||||
|
||||
// Read the stored column directly so the read-side clamp cannot mask a writer that forgot to force Bad.
|
||||
(await db.ScalarAsync($"SELECT COUNT(*) FROM {db.Conn.Q("Sample")} WHERE StatusCode >= 0")).ShouldBe(0);
|
||||
var raw = (await db.Source().ReadRawAsync("empty", At(0), At(10), 0, Ct)).Samples;
|
||||
raw.Select(s => s.StatusCode).ShouldBe([BadNoData, BadNoData, BadNoCommunication]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_legacy_good_Empty_row_is_clamped_to_BadNoData_on_Raw_and_AtTime()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
// Seeded with plain INSERTs, bypassing the writer: what a pre-fix build could have stored.
|
||||
await db.SeedAsync("legacy",
|
||||
(At(0), HistorianValue.FromDouble(10), ReadTestDb.Good),
|
||||
(At(10), HistorianValue.Empty, ReadTestDb.Good),
|
||||
(At(20), HistorianValue.Empty, ReadTestDb.Uncertain),
|
||||
(At(30), HistorianValue.FromDouble(40), ReadTestDb.Good));
|
||||
|
||||
var raw = (await db.Source().ReadRawAsync("legacy", At(0), At(100), 0, Ct)).Samples;
|
||||
raw.Select(s => s.StatusCode).ShouldBe([ReadTestDb.Good, BadNoData, BadNoData, ReadTestDb.Good]);
|
||||
|
||||
var at = (await db.Source().ReadAtTimeAsync("legacy", [At(10), At(15), At(25)], Ct)).Samples;
|
||||
at.ShouldAllBe(s => s.Value == null && s.StatusCode == BadNoData);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_updater_refuses_a_non_bad_Empty_and_accepts_a_bad_one()
|
||||
{
|
||||
var db = await ReadTestDb.CreateAsync(fx);
|
||||
var r = await Updater(db).UpdateDataAsync("u", HistoryUpdateMode.Insert, [
|
||||
new(At(0), HistorianValue.Empty, 0u),
|
||||
new(At(1), HistorianValue.Empty, 0x40000000u),
|
||||
new(At(2), HistorianValue.Empty, BadNoCommunication),
|
||||
new(At(3), HistorianValue.FromDouble(1), 0u),
|
||||
], Ct);
|
||||
r.ShouldBe([BadInvalidArgument, BadInvalidArgument, SqlHistorianStatusCodes.GoodEntryInserted,
|
||||
SqlHistorianStatusCodes.GoodEntryInserted]);
|
||||
(await db.Source().ReadRawAsync("u", At(0), At(10), 0, Ct)).Samples
|
||||
.Select(s => s.StatusCode).ShouldBe([BadNoCommunication, 0u]);
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -42,8 +42,9 @@ internal sealed class ReadTestDb
|
||||
return new ReadTestDb(o, conn, boot);
|
||||
}
|
||||
|
||||
/// <summary>A fresh data source (fresh catalog cache) over this schema.</summary>
|
||||
public SqlServerHistorianDataSource Source() => new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot));
|
||||
/// <summary>A fresh data source (fresh catalog cache) over this schema; <paramref name="clock"/> is its "now".</summary>
|
||||
public SqlServerHistorianDataSource Source(TimeProvider? clock = null) =>
|
||||
new(Conn, Boot, new SqlServerTagCatalog(Conn, Boot), timeProvider: clock);
|
||||
|
||||
/// <summary>Inserts a tag row and the given samples. ServerTimeUtc = source time + 1 s (so tests can tell them apart).</summary>
|
||||
public async Task<int> SeedAsync(string tag, params (DateTime T, HistorianValue V, uint Status)[] samples)
|
||||
@@ -237,7 +238,8 @@ public sealed class RawReadTests(SqlHistorianFixture fx)
|
||||
ConnectionString = fx.ConnectionString, Schema = SqlHistorianFixture.UniqueSchema(), AutoCreateSchema = false,
|
||||
};
|
||||
var conn = new SqlHistorianConnection(o);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o);
|
||||
var clock = new FixedClock(DateTimeOffset.UtcNow);
|
||||
var boot = new SqlServerSchemaBootstrapper(conn, o, timeProvider: clock);
|
||||
var src = new SqlServerHistorianDataSource(conn, boot, new SqlServerTagCatalog(conn, boot));
|
||||
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => src.ReadRawAsync("A", At(0), At(1), 0, Ct));
|
||||
@@ -251,6 +253,7 @@ public sealed class RawReadTests(SqlHistorianFixture fx)
|
||||
// Another node creates the schema; the same data source then succeeds (bootstrap failure is not latched).
|
||||
var creator = new SqlServerHistorianOptions { ConnectionString = o.ConnectionString, Schema = o.Schema };
|
||||
await new SqlServerSchemaBootstrapper(new SqlHistorianConnection(creator), creator).EnsureAsync(Ct);
|
||||
clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration; // past the 5 s failure cache (M-2)
|
||||
(await src.ReadRawAsync("A", At(0), At(1), 0, Ct)).Samples.ShouldBeEmpty();
|
||||
src.GetHealthSnapshot().ConsecutiveFailures.ShouldBe(0);
|
||||
}
|
||||
|
||||
+29
-2
@@ -77,10 +77,37 @@ public sealed class SchemaBootstrapIntegrationTests(SqlHistorianFixture fx)
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var checker = Boot(Opts(s, auto: false));
|
||||
var clock = new FixedClock(DateTimeOffset.UtcNow);
|
||||
var o = Opts(s, auto: false);
|
||||
var checker = new SqlServerSchemaBootstrapper(new SqlHistorianConnection(o), o, timeProvider: clock);
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => checker.EnsureAsync(CancellationToken.None));
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None);
|
||||
await checker.EnsureAsync(CancellationToken.None); // same instance retries
|
||||
clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration;
|
||||
await checker.EnsureAsync(CancellationToken.None); // same instance retries once the failure cache expires
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// M-2: within <see cref="SqlServerSchemaBootstrapper.FailureCacheDuration"/> of a failure the bootstrapper
|
||||
/// re-throws it without touching SQL — proven by creating the schema in between: a call that went to SQL
|
||||
/// would now pass.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_bootstrap_failure_is_cached_for_five_seconds_then_retried()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var s = SqlHistorianFixture.UniqueSchema();
|
||||
var clock = new FixedClock(DateTimeOffset.UtcNow);
|
||||
var o = Opts(s, auto: false);
|
||||
var checker = new SqlServerSchemaBootstrapper(new SqlHistorianConnection(o), o, timeProvider: clock);
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => checker.EnsureAsync(CancellationToken.None));
|
||||
|
||||
await Boot(Opts(s)).EnsureAsync(CancellationToken.None); // the schema now exists
|
||||
|
||||
clock.Now += SqlServerSchemaBootstrapper.FailureCacheDuration - TimeSpan.FromTicks(1);
|
||||
await Should.ThrowAsync<HistorianSchemaMissingException>(() => checker.EnsureAsync(CancellationToken.None));
|
||||
|
||||
clock.Now += TimeSpan.FromTicks(1);
|
||||
await checker.EnsureAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+3
-2
@@ -89,7 +89,7 @@ public sealed class WriteTests(SqlHistorianFixture fx)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_finite_doubles_store_as_Empty_with_their_status_and_do_not_fail_the_write()
|
||||
public async Task Non_finite_doubles_store_as_Empty_forced_Bad_and_do_not_fail_the_write()
|
||||
{
|
||||
fx.SkipIfUnavailable();
|
||||
var (w, s) = Make();
|
||||
@@ -101,7 +101,8 @@ public sealed class WriteTests(SqlHistorianFixture fx)
|
||||
rows.Count.ShouldBe(3);
|
||||
rows[0].Kind.ShouldBe((byte)HistorianValueKind.Empty);
|
||||
rows[0].N.ShouldBeNull();
|
||||
rows[0].Status.ShouldBe(unchecked((int)0x40000000u));
|
||||
rows[0].Status.ShouldBe(unchecked((int)SqlHistorianStatusCodes.BadOutOfRange)); // Uncertain NaN ⇒ Bad (I-1)
|
||||
rows[1].Status.ShouldBe(unchecked((int)SqlHistorianStatusCodes.BadOutOfRange)); // Good +Inf ⇒ Bad (I-1)
|
||||
rows[2].N.ShouldBe(7);
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -32,6 +32,36 @@ public sealed class SqlServerHistorianProviderTests
|
||||
p.CreateUpdater(sp).ShouldBeOfType<SqlServerHistorianDataUpdater>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Supported_kinds_are_a_frozen_set() =>
|
||||
Make().SupportedKinds.ShouldBeAssignableTo<System.Collections.Frozen.FrozenSet<HistorianValueKind>>();
|
||||
|
||||
/// <summary>
|
||||
/// An invalid schema must not fail provider construction (the host resolves the provider even when no
|
||||
/// consumer is enabled); it surfaces only when a seam is actually built.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void An_invalid_schema_fails_only_when_a_seam_is_built()
|
||||
{
|
||||
var p = new SqlServerHistorianProvider(new SqlServerHistorianOptions { ConnectionString = "x", Schema = "bad;schema" });
|
||||
p.Name.ShouldBe("SqlServer");
|
||||
Should.Throw<ArgumentException>(() => p.CreateDataSource(new NullSp()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_seam_shares_one_connection_bootstrapper_and_catalog()
|
||||
{
|
||||
var sp = new NullSp();
|
||||
var p = Make();
|
||||
var a = (SqlServerHistorianDataSource)p.CreateDataSource(sp);
|
||||
var b = (SqlServerHistorianDataSource)p.CreateDataSource(sp);
|
||||
Field(a, "_bootstrap").ShouldBeSameAs(Field(b, "_bootstrap"));
|
||||
Field(a, "_catalog").ShouldBeSameAs(Field(b, "_catalog"));
|
||||
}
|
||||
|
||||
private static object? Field(object o, string name) =>
|
||||
o.GetType().GetField(name, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!.GetValue(o);
|
||||
|
||||
private sealed class NullSp : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
|
||||
+17
@@ -22,6 +22,23 @@ public sealed class StatusCodeOracleTests
|
||||
((uint)Opc.Ua.AggregateBits.Interpolated).ShouldBe(HistorianInfoBits.HistorianInterpolated);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A stored status that already carries a historian bit (a sample written via HistoryUpdate with the Calculated
|
||||
/// bit, say) must come back with ONE historian bit, never both: the existing pair is masked off first. The SDK
|
||||
/// agrees — <c>SetAggregateBits</c> replaces, it does not OR.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Setting_one_historian_bit_clears_the_other()
|
||||
{
|
||||
var calculatedUncertain = SqlHistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianCalculated;
|
||||
var interpolated = HistorianInfoBits.WithInterpolated(calculatedUncertain);
|
||||
interpolated.ShouldBe(SqlHistorianStatusCodes.UncertainDataSubNormal | 0x400u | HistorianInfoBits.HistorianInterpolated);
|
||||
new Opc.Ua.StatusCode(interpolated).AggregateBits.ShouldBe(Opc.Ua.AggregateBits.Interpolated);
|
||||
|
||||
var interpolatedGood = 0x400u | HistorianInfoBits.HistorianInterpolated;
|
||||
HistorianInfoBits.WithCalculated(interpolatedGood).ShouldBe(0x400u | HistorianInfoBits.HistorianCalculated);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Info_type_is_data_value() =>
|
||||
HistorianInfoBits.InfoTypeDataValue.ShouldBe(0x400u);
|
||||
|
||||
+19
-5
@@ -9,18 +9,32 @@ public sealed class ValueWriterPrepareTests
|
||||
private static readonly DateTime T = new(2026, 10, 9, 1, 2, 3, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Non_finite_doubles_become_Empty_keeping_status()
|
||||
public void Non_finite_doubles_become_Empty_and_are_forced_Bad()
|
||||
{
|
||||
const uint status = 0x40000000u;
|
||||
const uint uncertain = 0x40000000u;
|
||||
const uint badNoCommunication = 0x80310000u;
|
||||
var rows = SqlServerHistorianValueWriter.Prepare(
|
||||
[
|
||||
new(T, HistorianValue.FromDouble(double.NaN), status),
|
||||
new(T, HistorianValue.FromDouble(double.NaN), uncertain),
|
||||
new(T.AddTicks(1), HistorianValue.FromDouble(double.PositiveInfinity), 0),
|
||||
new(T.AddTicks(2), HistorianValue.FromDouble(double.NegativeInfinity), 0),
|
||||
new(T.AddTicks(2), HistorianValue.FromDouble(double.NegativeInfinity), badNoCommunication),
|
||||
new(T.AddTicks(3), HistorianValue.FromDouble(1.5), 0),
|
||||
], () => T);
|
||||
rows.Select(r => r.Value.Kind).ShouldBe([HistorianValueKind.Empty, HistorianValueKind.Empty, HistorianValueKind.Empty, HistorianValueKind.Double]);
|
||||
rows[0].StatusCode.ShouldBe(status);
|
||||
rows.Select(r => r.StatusCode).ShouldBe([SqlHistorianStatusCodes.BadOutOfRange, SqlHistorianStatusCodes.BadOutOfRange,
|
||||
badNoCommunication, 0u]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_non_bad_Empty_is_stored_BadNoData_and_a_bad_Empty_keeps_its_code()
|
||||
{
|
||||
var rows = SqlServerHistorianValueWriter.Prepare(
|
||||
[
|
||||
new(T, HistorianValue.Empty, 0),
|
||||
new(T.AddTicks(1), HistorianValue.Empty, 0x40A40000u),
|
||||
new(T.AddTicks(2), HistorianValue.Empty, 0x80310000u),
|
||||
], () => T);
|
||||
rows.Select(r => r.StatusCode).ShouldBe([SqlHistorianStatusCodes.BadNoData, SqlHistorianStatusCodes.BadNoData, 0x80310000u]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user