fix(sql): browse-commit states a topByTimestamp row selector (#552)

`RawBrowseCommitMapper.BuildSqlTagConfig` emitted a WideRow blob carrying only
table + columnName. A wide-row tag must also say WHICH row it reads, so both
`SqlEquipmentTagParser` (the runtime authority) and `SqlTagConfigModel.Validate`
(the authoring gate) refused it — a browse-committed Sql tag was never readable,
first silently as BadNodeIdUnknown and, since #551 put the gate on the import,
loudly at commit.

`SqlBrowseSession` now states a fourth address field: the table's preferred
date/time-typed column, computed from the columns its attributes call ALREADY
read (no extra catalog round-trip). Candidacy is decided by
`ISqlDialect.MapColumnType` reporting `DriverDataType.DateTime` — never by column
name, which would order a wide-row read by a string column that merely looks like
a timestamp. Multiple candidates tie-break on FIRST IN CATALOG ORDER (every
dialect's column SQL orders by ordinal position), so the pick is stable across
calls and servers; it is a visible, editable default the typed editor round-trips.

No candidate (a table with no date/time column) is an operator-actionable refusal,
not an invented selector: the only remaining selector is a whereColumn/whereValue
pair whose value is data, not schema, so `DescribeUncommittableLeaf` refuses that
leaf by name, names the table, and points at the tag editor.

The session and the mapper now agree via `SqlBrowseAddressFields` constants rather
than two independently spelled string literals — a disagreement there is silent,
and is exactly the shape this defect had.

`RawTagConfigGateTests`' Sql leg flips from "records the gap" to "browse-commit
output passes the gate", with the no-candidate case pinned as its own test.
This commit is contained in:
Joseph Doherty
2026-08-10 01:20:12 -04:00
parent f896e0631c
commit 7498d39e5b
8 changed files with 650 additions and 28 deletions
+27
View File
@@ -169,6 +169,33 @@ Browse works against all three constructed providers; the catalog query is the d
(`INFORMATION_SCHEMA` on SQL Server and Postgres — with `pg_catalog` / `information_schema` (`INFORMATION_SCHEMA` on SQL Server and Postgres — with `pg_catalog` / `information_schema`
themselves excluded — and the `ALL_*` views on Oracle). themselves excluded — and the `ALL_*` views on Oracle).
### The committed row selector (Gitea #552)
A browse-committed column becomes a **WideRow** tag, and a wide-row tag must say *which row* it reads
— the table and column alone are not a binding. The browse session therefore states a fourth address
field alongside schema/table/column: the table's preferred **date/time-typed** column, which the
commit writes as `rowSelector.topByTimestamp` ("read the newest row"). Before this the commit emitted
a selector-less blob that `SqlEquipmentTagParser` and the authoring gate both refuse, so no
browse-committed Sql tag was ever readable.
Two properties of the choice are deliberate:
- **Candidacy is by declared TYPE, never by column name.** The session asks
`ISqlDialect.MapColumnType` for `DriverDataType.DateTime` — the same metadata the browse tree already
renders per column. A name heuristic (`*_ts`, `*time*`) would happily order a wide-row read by a
*string* column called `timestamp`, binding the tag to whichever row sorts last lexically.
- **The tie-break is first in catalog order** (every dialect's column query orders by ordinal
position), so the pick is stable across calls, connections and servers. On a
`CreatedUtc`/`ModifiedUtc` pair it takes the earlier-declared one. It is a **visible, editable
default**: the typed editor shows the selector and the operator can change it — to the other
timestamp, or to a `whereColumn`/`whereValue` pair entirely.
**A table with no date/time column cannot be bound by browse, and the commit says so.** The remaining
selector is a `whereColumn`/`whereValue` pair whose value is *data*, not schema, so nothing in a
catalog can supply it. `RawBrowseCommitMapper.DescribeUncommittableLeaf` refuses that leaf by name,
names the table, and points at the tag editor; no selector is invented, and the mapper's blob stays
selector-less rather than dropping to a generic `{"address": …}` the typed editor cannot read.
## Authoring in the AdminUI ## Authoring in the AdminUI
- **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the - **Driver config** — `/raw` → driver node → *Configure*. Sql is a **single-connection** driver: the
@@ -1,6 +1,8 @@
using System.Data; using System.Data;
using System.Data.Common; using System.Data.Common;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
@@ -149,12 +151,34 @@ internal sealed class SqlBrowseSession : IBrowseSession
var columns = await ReadColumnsAsync( var columns = await ReadColumnsAsync(
reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false); reference.Schema, reference.Table!, cancellationToken).ConfigureAwait(false);
// Computed from the columns ALREADY READ for this call — the catalog is not queried a second time.
var timestampColumn = PreferredTimestampColumn(columns);
// Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the // Ordinal: a column NodeId is only ever minted from this same catalog output, so its case is the
// catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation // catalog's own. A case-insensitive match would pick the wrong column on a case-sensitive collation
// that legitimately carries both "Value" and "value". // that legitimately carries both "Value" and "value".
foreach (var column in columns) foreach (var column in columns)
{ {
if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue; if (!string.Equals(column.Name, reference.Column, StringComparison.Ordinal)) continue;
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
// schema is carried separately so the commit mapper can qualify the table itself rather
// than parsing a joined string back apart.
var addressFields = new Dictionary<string, string>(StringComparer.Ordinal)
{
[SqlBrowseAddressFields.Schema] = reference.Schema,
[SqlBrowseAddressFields.Table] = reference.Table!,
[SqlBrowseAddressFields.ColumnName] = column.Name,
};
// The third part of a wide-row binding: WHICH row. Stated only when the catalog actually names
// a date/time column; the key is absent otherwise, never blank, so the mapper can tell "this
// table cannot be bound by browse" from "an older session said nothing".
if (timestampColumn is not null)
addressFields[SqlBrowseAddressFields.TopByTimestamp] = timestampColumn;
return return
[ [
new AttributeInfo( new AttributeInfo(
@@ -163,17 +187,7 @@ internal sealed class SqlBrowseSession : IBrowseSession
IsArray: false, IsArray: false,
SecurityClass: ReadOnlySecurityClass, SecurityClass: ReadOnlySecurityClass,
IsAlarm: false, IsAlarm: false,
// A column NAME alone cannot address a Sql tag — the table has to travel with it, or the AddressFields: addressFields),
// browse-commit has nothing to build a SqlTagConfigModel from and falls through to the
// generic {"address": ...} blob the typed editor cannot read (deferment.md G-6). The
// schema is carried separately so the commit mapper can qualify the table itself rather
// than parsing a joined string back apart.
AddressFields: new Dictionary<string, string>(StringComparer.Ordinal)
{
["schema"] = reference.Schema,
["table"] = reference.Table!,
["columnName"] = column.Name,
}),
]; ];
} }
@@ -245,6 +259,45 @@ internal sealed class SqlBrowseSession : IBrowseSession
return nodes; return nodes;
} }
/// <summary>
/// Picks the table's preferred <c>topByTimestamp</c> column — the one a browse-committed wide-row tag
/// orders by to read the newest row (Gitea #552) — or <see langword="null"/> when the table carries no
/// date/time column at all.
/// </summary>
/// <param name="columns">The table's columns, in the catalog order the dialect's column SQL returns.</param>
/// <returns>The chosen column name, or <see langword="null"/> when there is no candidate.</returns>
/// <remarks>
/// <para><b>Candidacy is decided by the column's declared TYPE, never by its name.</b> A name test
/// (<c>*_ts</c>, <c>*time*</c>, …) is a guess that reads as knowledge: it is locale-dependent, it
/// silently misses <c>Recorded</c>, and — the part that actually breaks data — it would happily order
/// a wide-row read by a <em>string</em> column called <c>timestamp</c>, binding the tag to whatever
/// row sorts last lexically. So the only input is
/// <see cref="ISqlDialect.MapColumnType"/> reporting <see cref="DriverDataType.DateTime"/>, which is
/// the same catalog metadata the browse tree already renders per column, on every dialect.</para>
/// <para><b>Tie-break: FIRST in catalog order.</b> Every dialect's <see cref="ISqlDialect.ListColumnsSql"/>
/// orders by ordinal position (<c>ORDINAL_POSITION</c> / <c>column_id</c>), so "first" means the
/// earliest-declared date/time column and is stable across calls, connections and servers — an
/// unordered "any candidate" would let two operators browsing the same table commit two differently
/// bound tags. On the usual multi-candidate shape (a <c>CreatedUtc</c>/<c>ModifiedUtc</c> pair) it
/// picks the earlier-declared one, which is a preference and not a fact about the data.</para>
/// <para><b>It is a visible, editable DEFAULT, not a hidden binding.</b> The commit writes it into the
/// tag's <c>rowSelector.topByTimestamp</c>, where the typed Sql editor shows it and the operator can
/// change it — to the other timestamp, or to a <c>whereColumn</c>/<c>whereValue</c> pair entirely.
/// Nothing about the choice is recoverable only from the browse session.</para>
/// <para>The browsed column itself is deliberately NOT excluded: picking a table's timestamp column as
/// a tag reads the newest row's timestamp, which is a legitimate (and self-consistent) tag.</para>
/// </remarks>
private string? PreferredTimestampColumn(IReadOnlyList<(string Name, string DataType)> columns)
{
foreach (var column in columns)
{
if (string.IsNullOrWhiteSpace(column.Name)) continue;
if (_dialect.MapColumnType(column.DataType) == DriverDataType.DateTime) return column.Name;
}
return null;
}
/// <summary> /// <summary>
/// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather /// Reads one table's columns. A table that no longer exists (or never did) yields an empty list rather
/// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less /// than an error: the catalog answering "no rows" is indistinguishable from a genuinely column-less
@@ -0,0 +1,37 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
/// <summary>
/// The keys <c>SqlBrowseSession</c> states in a browsed column's <c>AttributeInfo.AddressFields</c>, and
/// that <c>RawBrowseCommitMapper</c> reads back when it builds the committed tag's <c>TagConfig</c>.
/// <para><b>Why a shared constant rather than a literal at each end.</b> A Sql tag's binding is a tuple
/// (table + column + which row), so — exactly as for MQTT's Sparkplug tuple and
/// <c>MqttTagConfigKeys</c> — the producing session states the parts and the mapper must never
/// re-infer them. Two independently spelled string literals agree by coincidence, and a disagreement is
/// silent: the mapper simply sees no field, and emits a config missing whatever the session was trying
/// to say. That is precisely how Gitea #552 shipped — a wide-row blob with no <c>rowSelector</c>, which
/// both <see cref="SqlEquipmentTagParser"/> and the authoring gate refuse.</para>
/// <para>These are the <em>browse channel's</em> names, not the tag blob's schema. Three of them happen
/// to match a <c>TagConfig</c> key because the mapper writes them straight through;
/// <see cref="TopByTimestamp"/> matches the key <em>nested inside</em> <c>rowSelector</c> — deliberately
/// not spelled <c>timestampColumn</c>, which is the blob's unrelated top-level source-timestamp
/// field.</para>
/// </summary>
public static class SqlBrowseAddressFields
{
/// <summary>The catalog schema owning the browsed column's table (e.g. <c>dbo</c>, <c>sales</c>).</summary>
public const string Schema = "schema";
/// <summary>The unqualified table (or view) name the browsed column belongs to.</summary>
public const string Table = "table";
/// <summary>The browsed column's own name — the wide-row tag's value column.</summary>
public const string ColumnName = "columnName";
/// <summary>
/// The table's preferred date/time-typed column, when it has one: the commit writes it as the
/// wide-row <c>rowSelector.topByTimestamp</c>, i.e. "read the newest row". Absent when the table
/// carries no date/time column at all, which is not a shape a browse can bind — see
/// <c>RawBrowseCommitMapper.DescribeUncommittableLeaf</c>.
/// </summary>
public const string TopByTimestamp = "topByTimestamp";
}
@@ -341,18 +341,43 @@ public static class RawBrowseCommitMapper
/// <param name="addressFields">The structured address the browse session stated, if any.</param> /// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c> when the leaf is committable.</returns> /// <returns>The operator-facing reason, or <c>null</c> when the leaf is committable.</returns>
/// <remarks> /// <remarks>
/// Only MQTT can fail this today, and only in one shape: a leaf whose attribute lookup returned /// <para>
/// nothing, so no address was stated. Committing it anyway is the defect this whole seam exists /// Exactly two drivers can fail this, and both for the same underlying reason: their binding is a
/// to close — a Sparkplug tuple cannot be reconstructed from the node id, so the row would deploy /// TUPLE, so a leaf can be selectable in the tree and still not carry enough to bind. Every
/// clean and then report <c>BadNodeIdUnknown</c> forever with nothing to point at. Every /// single-reference driver is unaffected — its address IS the node id.
/// single-reference driver is unaffected: its address IS the node id. /// </para>
/// <para>
/// <b>MQTT</b>: a leaf whose attribute lookup returned nothing, so no address was stated. Committing
/// it anyway is the defect this whole seam exists to close — a Sparkplug tuple cannot be
/// reconstructed from the node id, so the row would deploy clean and then report
/// <c>BadNodeIdUnknown</c> forever with nothing to point at.
/// </para>
/// <para>
/// <b>Sql</b> (Gitea #552): a column of a table carrying no date/time column. A wide-row tag must say
/// which ROW it reads, and with no timestamp to order by the only remaining selector is a
/// <c>whereColumn</c>/<c>whereValue</c> pair — whose value is <em>data</em>, not schema, and therefore
/// is not something any browse can know. Refusing here rather than letting the import's own gate
/// refuse the blob is what makes the message name the table and the fix; the gate would only report
/// that a row selector is missing, on every selected column at once.
/// </para>
/// </remarks> /// </remarks>
public static string? DescribeUncommittableLeaf( public static string? DescribeUncommittableLeaf(
string driverType, string driverType,
string browseName, string browseName,
IReadOnlyDictionary<string, string>? addressFields) IReadOnlyDictionary<string, string>? addressFields)
{ {
if (!Is(driverType, DriverTypeNames.Mqtt)) return null; if (Is(driverType, DriverTypeNames.Mqtt)) return DescribeUncommittableMqttLeaf(browseName, addressFields);
if (Is(driverType, DriverTypeNames.Sql)) return DescribeUncommittableSqlLeaf(browseName, addressFields);
return null;
}
/// <summary>Describes an MQTT leaf that states no address at all, or <c>null</c> when it states one.</summary>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
private static string? DescribeUncommittableMqttLeaf(
string browseName, IReadOnlyDictionary<string, string>? addressFields)
{
if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null; if (TryGetField(addressFields, MqttTagConfigKeys.MetricName) is not null) return null;
if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null; if (TryGetField(addressFields, MqttTagConfigKeys.Topic) is not null) return null;
@@ -361,6 +386,31 @@ public static class RawBrowseCommitMapper
+ "the tag manually."; + "the tag manually.";
} }
/// <summary>
/// Describes a Sql column leaf whose wide-row binding cannot be completed from the catalog, or
/// <c>null</c> when it can (Gitea #552).
/// </summary>
/// <param name="browseName">The leaf's browse name, for the message.</param>
/// <param name="addressFields">The structured address the browse session stated, if any.</param>
/// <returns>The operator-facing reason, or <c>null</c>.</returns>
private static string? DescribeUncommittableSqlLeaf(
string browseName, IReadOnlyDictionary<string, string>? addressFields)
{
if (TryGetField(addressFields, SqlBrowseAddressFields.Table) is not { } table)
{
return $"'{browseName}' could not be committed: the browse session reported no Sql table for it "
+ "(its attribute lookup returned nothing). Re-open the browser and re-select it, or author "
+ "the tag manually.";
}
if (TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp) is not null) return null;
return $"'{browseName}' could not be committed: '{table}' has no date/time column, so which ROW the "
+ "tag reads cannot be derived from the schema. Author it in the tag editor with a "
+ "whereColumn/whereValue row selector — the value it matches on is data, not schema, so the "
+ "browser cannot supply it.";
}
/// <summary> /// <summary>
/// Reads the browse leaf's driver-native data type out of the stated address fields, falling /// Reads the browse leaf's driver-native data type out of the stated address fields, falling
/// back to <paramref name="fallback"/> — the driver model's own default — when the browser /// back to <paramref name="fallback"/> — the driver model's own default — when the browser
@@ -438,17 +488,49 @@ public static class RawBrowseCommitMapper
/// <para>Falls back to the generic key when the fields are absent — an older browser build, or a /// <para>Falls back to the generic key when the fields are absent — an older browser build, or a
/// hand-made selection — rather than emitting a half-built Sql blob.</para> /// hand-made selection — rather than emitting a half-built Sql blob.</para>
/// </summary> /// </summary>
/// <param name="address">The leaf's browse node id — the column-name fallback only.</param>
/// <param name="addressFields">The structured address the browse session stated.</param>
/// <returns>The serialised <c>TagConfig</c> JSON.</returns>
/// <remarks>
/// <para>
/// <b>A wide-row tag needs three things, not two: the table, the column, and WHICH ROW</b>
/// (Gitea #552). This branch used to emit only the first two, and a selector-less wide-row blob is
/// refused by <c>SqlEquipmentTagParser</c> and by <c>SqlTagConfigModel.Validate</c> alike — so every
/// browse-committed Sql tag was unreadable, first silently (<c>BadNodeIdUnknown</c> forever) and then,
/// once #551 put the gate on the import, loudly at commit.
/// </para>
/// <para>
/// <b>The row selector is READ from the session's stated fields, never inferred here</b> — same rule
/// as MQTT's Sparkplug tuple. The session holds the table's column metadata and names a date/time
/// column by TYPE (see <c>SqlBrowseSession.PreferredTimestampColumn</c>); this mapper has only a
/// column name and a table name, from which any selector it invented would be a guess about data.
/// </para>
/// <para>
/// <b>When no candidate was stated the blob stays selector-less on purpose.</b> The alternatives are
/// worse: a fabricated selector binds the tag to a row nobody chose, and dropping to the generic
/// <c>{"address": …}</c> key throws away the table the session did know. Kept in the wide-row shape,
/// the blob opens in the typed editor with everything but the selector filled in — and the commit
/// itself does not reach here, because <see cref="DescribeUncommittableLeaf"/> refuses that leaf in
/// words first.
/// </para>
/// <para>
/// The blob's top-level <c>timestampColumn</c> (the SOURCE-timestamp column, which makes the driver
/// stamp values with the row's time instead of the poll time) is deliberately left unset: ordering by
/// a column is not a claim that it timestamps the value, and turning it on silently would change every
/// browse-committed tag's published SourceTimestamp.
/// </para>
/// </remarks>
private static string BuildSqlTagConfig(string address, IReadOnlyDictionary<string, string>? addressFields) private static string BuildSqlTagConfig(string address, IReadOnlyDictionary<string, string>? addressFields)
{ {
if (addressFields is null if (addressFields is null
|| !addressFields.TryGetValue("table", out var table) || !addressFields.TryGetValue(SqlBrowseAddressFields.Table, out var table)
|| string.IsNullOrWhiteSpace(table)) || string.IsNullOrWhiteSpace(table))
{ {
return WriteSingleKey("address", address); return WriteSingleKey("address", address);
} }
addressFields.TryGetValue("schema", out var schema); addressFields.TryGetValue(SqlBrowseAddressFields.Schema, out var schema);
addressFields.TryGetValue("columnName", out var columnName); addressFields.TryGetValue(SqlBrowseAddressFields.ColumnName, out var columnName);
// Qualify the table with its schema when the schema is not the default, so a tag authored against // Qualify the table with its schema when the schema is not the default, so a tag authored against
// "sales.Readings" cannot silently resolve to "dbo.Readings". // "sales.Readings" cannot silently resolve to "dbo.Readings".
@@ -461,6 +543,13 @@ public static class RawBrowseCommitMapper
Model = SqlTagModel.WideRow, Model = SqlTagModel.WideRow,
Table = qualified, Table = qualified,
ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName, ColumnName = string.IsNullOrWhiteSpace(columnName) ? address : columnName,
// Null when the session stated none: SqlRowSelectorModel.ToJsonObject then returns null and the
// "rowSelector" key is omitted entirely, rather than written as an empty or blank-valued object.
RowSelector = new SqlRowSelectorModel
{
TopByTimestamp = TryGetField(addressFields, SqlBrowseAddressFields.TopByTimestamp),
},
}.ToJson(); }.ToJson();
} }
@@ -3,6 +3,7 @@ using Shouldly;
using Xunit; using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests; using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests; namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests;
@@ -398,4 +399,116 @@ public sealed class SqlBrowseSessionTests
// Every catalog command went through the provider hook (Oracle's BindByName lives there). // Every catalog command went through the provider hook (Oracle's BindByName lives there).
dialect.ConfiguredCommands.ShouldBe(2); dialect.ConfiguredCommands.ShouldBe(2);
} }
// ---- the wide-row ROW SELECTOR candidate (Gitea #552) -----------------------------------------
/// <summary>
/// The session states the three parts of a Sql binding it can know — the table, the column, and the
/// table's own date/time column, which the commit turns into <c>rowSelector.topByTimestamp</c>.
/// Without the third the committed blob is a wide-row tag that never says which ROW it reads, which
/// the driver's parser and the authoring gate both refuse.
/// </summary>
[Fact]
public async Task AttributesAsync_statesTheTablesSoleDateTimeColumn_asTheRowSelectorCandidate()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema,
SqliteBrowseFixture.TimestampedTable,
SqliteBrowseFixture.TimestampedTableValueColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields[SqlBrowseAddressFields.Schema].ShouldBe(SqliteDialect.MainSchema);
fields[SqlBrowseAddressFields.Table].ShouldBe(SqliteBrowseFixture.TimestampedTable);
fields[SqlBrowseAddressFields.ColumnName].ShouldBe(SqliteBrowseFixture.TimestampedTableValueColumn);
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn);
}
/// <summary>
/// The tie-break is FIRST IN CATALOG ORDER (every dialect's column SQL orders by ordinal position),
/// so two operators browsing the same table commit the same binding.
/// <para>The fixture's two candidates are named so catalog order and alphabetical order DISAGREE —
/// <c>zulu_time</c> is declared first and sorts last. A tie-break that quietly became "any candidate"
/// or "sorted by name" fails here rather than passing by luck.</para>
/// </summary>
[Fact]
public async Task AttributesAsync_breaksAMultiCandidateTie_onCatalogOrderNotName()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema,
SqliteBrowseFixture.MultiTimeTable,
SqliteBrowseFixture.MultiTimeValueColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(
SqliteBrowseFixture.MultiTimeFirstColumn,
"the first date/time column in catalog order wins, not the first alphabetically");
// Guards the fixture itself: the assertion above is only a tie-break test while the two disagree.
string.CompareOrdinal(
SqliteBrowseFixture.MultiTimeFirstColumn, SqliteBrowseFixture.MultiTimeSecondColumn)
.ShouldBeGreaterThan(0, "the expected pick must NOT also be the alphabetically first one");
}
/// <summary>
/// A table with no date/time column states NO candidate — the key is absent, never blank — so the
/// commit can tell "this table cannot be bound by browse" from "the session said nothing".
/// <para>The control that makes this sharp: <c>TagValues</c> holds <c>sample_ts</c>, a column that
/// LOOKS like a timestamp and is declared <c>TEXT</c>. Candidacy is decided by the mapped type, so a
/// name heuristic — which would order the wide-row read by a string column, binding the tag to
/// whichever row sorts last lexically — fails this test.</para>
/// </summary>
[Fact]
public async Task AttributesAsync_statesNoCandidate_whenNoColumnIsDateTimeTyped()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
await using var session = new SqlBrowseSession(db.Connection, new SqliteDialect());
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, SqliteBrowseFixture.RealColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields.ShouldContainKey(SqlBrowseAddressFields.Table);
fields.ShouldNotContainKey(SqlBrowseAddressFields.TopByTimestamp);
// The name-heuristic control, stated explicitly: the tempting column IS present and IS a string.
var stringTyped = await session.AttributesAsync(
SqlBrowseNodeId.ForColumn(SqliteDialect.MainSchema, SqliteBrowseFixture.KeyValueTable, "sample_ts"),
CancellationToken.None);
stringTyped.ShouldHaveSingleItem().DriverDataType.ShouldBe(nameof(DriverDataType.String));
}
/// <summary>
/// The candidate costs no extra catalog round-trip: it is computed from the columns the attributes
/// call already read. Counting the dialect's configured commands is the cheapest honest proxy —
/// one <c>ListColumnsSql</c> execution, exactly as before #552.
/// </summary>
[Fact]
public async Task AttributesAsync_computesTheCandidate_withoutASecondCatalogQuery()
{
await using var db = await SqliteBrowseFixture.CreateAsync();
var dialect = new ColonMarkerDialect();
await using var session = new SqlBrowseSession(db.Connection, dialect);
var nodeId = SqlBrowseNodeId.ForColumn(
SqliteDialect.MainSchema,
SqliteBrowseFixture.TimestampedTable,
SqliteBrowseFixture.TimestampedTableValueColumn);
var fields = (await session.AttributesAsync(nodeId, CancellationToken.None))
.ShouldHaveSingleItem().AddressFields.ShouldNotBeNull();
fields[SqlBrowseAddressFields.TopByTimestamp].ShouldBe(SqliteBrowseFixture.TimestampedTableTimeColumn);
dialect.ConfiguredCommands.ShouldBe(1);
}
} }
@@ -62,6 +62,40 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
/// <summary>A table name that exists in no catalog — the "vanished table" / zero-column probe.</summary> /// <summary>A table name that exists in no catalog — the "vanished table" / zero-column probe.</summary>
public const string NonExistentTable = "NoSuchTable"; public const string NonExistentTable = "NoSuchTable";
/// <summary>
/// A wide-row table with exactly ONE date/time-typed column — the sole-candidate case for the
/// browse-commit row selector (Gitea #552).
/// </summary>
/// <remarks>
/// Note <see cref="KeyValueTable"/> is the no-candidate control and needs no table of its own: its
/// <c>sample_ts</c> column is declared <c>TEXT</c>, so it is a timestamp by NAME and a
/// <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String"/> by type — exactly the
/// column a name heuristic would order a wide-row read by, and exactly the one the type test must
/// refuse.
/// </remarks>
public const string TimestampedTable = "Readings";
/// <summary>The one <c>DATETIME</c> column of <see cref="TimestampedTable"/>.</summary>
public const string TimestampedTableTimeColumn = "recorded";
/// <summary>A <see cref="TimestampedTable"/> value column — an ordinary wide-row signal.</summary>
public const string TimestampedTableValueColumn = "oven_temp";
/// <summary>
/// A table carrying TWO date/time columns, deliberately named so that catalog order and alphabetical
/// order disagree — <see cref="MultiTimeFirstColumn"/> is declared first but sorts last.
/// </summary>
public const string MultiTimeTable = "MultiStamped";
/// <summary>The FIRST-declared date/time column of <see cref="MultiTimeTable"/> — the expected pick.</summary>
public const string MultiTimeFirstColumn = "zulu_time";
/// <summary>The second-declared date/time column of <see cref="MultiTimeTable"/> — alphabetically first.</summary>
public const string MultiTimeSecondColumn = "alpha_time";
/// <summary>A <see cref="MultiTimeTable"/> value column, sitting BETWEEN the two timestamps.</summary>
public const string MultiTimeValueColumn = "level";
private readonly string _databasePath; private readonly string _databasePath;
private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection) private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
@@ -165,6 +199,17 @@ public sealed class SqliteBrowseFixture : IAsyncDisposable
"{ExoticColumn}" GEOGRAPHY NULL, "{ExoticColumn}" GEOGRAPHY NULL,
blob_col BLOB NULL blob_col BLOB NULL
); );
CREATE TABLE "{TimestampedTable}" (
"{TimestampedTableValueColumn}" REAL NULL,
"{TimestampedTableTimeColumn}" DATETIME NOT NULL
);
CREATE TABLE "{MultiTimeTable}" (
"{MultiTimeFirstColumn}" DATETIME NOT NULL,
"{MultiTimeValueColumn}" REAL NULL,
"{MultiTimeSecondColumn}" TIMESTAMP NULL
);
""").ConfigureAwait(false); """).ConfigureAwait(false);
// Separate statements: these names must be quoted by the seed itself, so keep them off the // Separate statements: these names must be quoted by the seed itself, so keep them off the
@@ -9,6 +9,7 @@ using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt; using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
@@ -671,4 +672,214 @@ public sealed class RawBrowseCommitMapperTests
DriverTypeNames.OpcUaClient, "ns=2;s=2", "Temperature", null, "Double", DriverTypeNames.OpcUaClient, "ns=2;s=2", "Temperature", null, "Double",
null, null, false), null, null, false),
}).ShouldBeEmpty(); }).ShouldBeEmpty();
// ---- BuildTagConfig: Sql (the address is a TUPLE, and the third part is WHICH ROW) — Gitea #552 --
/// <summary>The address a <c>SqlBrowseSession</c> states for a column of a table that has a timestamp.</summary>
/// <param name="schema">The owning schema.</param>
/// <param name="table">The owning table.</param>
/// <param name="column">The browsed column.</param>
/// <param name="timestampColumn">The table's preferred date/time column, or null to state none.</param>
/// <returns>The stated address fields.</returns>
private static Dictionary<string, string> SqlFields(
string schema = "dbo",
string table = "Readings",
string column = "oven_temp",
string? timestampColumn = "recorded")
{
var fields = new Dictionary<string, string>(StringComparer.Ordinal)
{
[SqlBrowseAddressFields.Schema] = schema,
[SqlBrowseAddressFields.Table] = table,
[SqlBrowseAddressFields.ColumnName] = column,
};
if (timestampColumn is not null) fields[SqlBrowseAddressFields.TopByTimestamp] = timestampColumn;
return fields;
}
[Fact]
public void BuildTagConfig_Sql_writes_the_row_selector_the_session_stated()
{
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", SqlFields());
var o = JsonNode.Parse(json)!.AsObject();
o["model"]!.GetValue<string>().ShouldBe(nameof(SqlTagModel.WideRow));
o["table"]!.GetValue<string>().ShouldBe("Readings");
o["columnName"]!.GetValue<string>().ShouldBe("oven_temp");
o["rowSelector"]!.AsObject()["topByTimestamp"]!.GetValue<string>().ShouldBe("recorded");
o.ContainsKey("address").ShouldBeFalse(); // never the generic fallback key
}
/// <summary>
/// The blob must satisfy BOTH gates, and they are two different implementations of one rule: the
/// AdminUI model (what the authoring gate and the typed editor run) and
/// <see cref="SqlEquipmentTagParser"/> (what the deployed driver runs). Before #552 the emitted blob
/// failed both — so a browse-committed Sql tag was never readable.
/// </summary>
[Fact]
public void BuildTagConfig_Sql_blob_is_accepted_by_the_model_AND_by_the_REAL_driver_parser()
{
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", SqlFields());
SqlTagConfigModel.FromJson(json).Validate().ShouldBeNull();
TagConfigValidator.Validate(DriverTypeNames.Sql, json).ShouldBeNull();
SqlEquipmentTagParser.TryParse(json, "Plant/Sql/dev1/OvenTemp", out var def).ShouldBeTrue();
def.Name.ShouldBe("Plant/Sql/dev1/OvenTemp"); // v3: identity is the RawPath
def.Model.ShouldBe(SqlTagModel.WideRow);
def.Table.ShouldBe("Readings");
def.ColumnName.ShouldBe("oven_temp");
def.RowSelectorTopByTimestamp.ShouldBe("recorded");
def.RowSelectorColumn.ShouldBeNull();
def.RowSelectorValue.ShouldBeNull();
}
/// <summary>
/// The falsification control for the pair above: the blob the mapper emitted BEFORE #552 — the same
/// bytes minus the selector — must be refused by both. Without it, the acceptance assertions could
/// pass against gates that accept anything.
/// </summary>
[Fact]
public void The_Sql_double_acceptance_is_falsifiable_a_selector_less_blob_is_refused_by_both()
{
var legacy = new JsonObject
{
["model"] = nameof(SqlTagModel.WideRow),
["table"] = "Readings",
["columnName"] = "oven_temp",
}.ToJsonString();
SqlTagConfigModel.FromJson(legacy).Validate().ShouldNotBeNullOrEmpty();
TagConfigValidator.Validate(DriverTypeNames.Sql, legacy).ShouldNotBeNullOrEmpty();
SqlEquipmentTagParser.TryParse(legacy, "Plant/Sql/dev1/OvenTemp", out _).ShouldBeFalse();
}
/// <summary>
/// The selector is a visible, editable DEFAULT: it survives a load→save through the typed editor's
/// model unchanged, and the operator can replace it with a where-pair, which must also still parse.
/// </summary>
[Fact]
public void BuildTagConfig_Sql_blob_round_trips_the_typed_editor()
{
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", SqlFields());
var loaded = SqlTagConfigModel.FromJson(json);
loaded.Model.ShouldBe(SqlTagModel.WideRow);
loaded.Table.ShouldBe("Readings");
loaded.ColumnName.ShouldBe("oven_temp");
loaded.RowSelector.TopByTimestamp.ShouldBe("recorded");
var resaved = loaded.ToJson();
SqlEquipmentTagParser.TryParse(resaved, "p", out var def).ShouldBeTrue();
def.RowSelectorTopByTimestamp.ShouldBe("recorded");
// …and the operator's own edit — swapping the browse default for a where-pair — is equally readable.
loaded.RowSelector.TopByTimestamp = null;
loaded.RowSelector.WhereColumn = "machine_id";
loaded.RowSelector.WhereValue = "7";
var edited = loaded.ToJson();
SqlTagConfigModel.FromJson(edited).Validate().ShouldBeNull();
SqlEquipmentTagParser.TryParse(edited, "p", out var editedDef).ShouldBeTrue();
editedDef.RowSelectorColumn.ShouldBe("machine_id");
editedDef.RowSelectorValue.ShouldBe("7");
editedDef.RowSelectorTopByTimestamp.ShouldBeNull();
}
/// <summary>
/// A non-default schema qualifies the table, and the selector column is a column of THAT table — the
/// two facts travel together from one session call, so neither can be attributed to the wrong table.
/// </summary>
[Fact]
public void BuildTagConfig_Sql_qualifies_a_non_default_schema_and_keeps_its_own_selector()
{
var json = RawBrowseCommitMapper.BuildTagConfig(
DriverTypeNames.Sql,
"level",
SqlFields(schema: "sales", table: "Readings", column: "level", timestampColumn: "sold_at"));
SqlEquipmentTagParser.TryParse(json, "p", out var def).ShouldBeTrue();
def.Table.ShouldBe("sales.Readings");
def.RowSelectorTopByTimestamp.ShouldBe("sold_at");
}
/// <summary>
/// No stated candidate ⇒ NO invented selector. The blob keeps the wide-row shape (so it opens in the
/// typed editor with the table and column already filled in) and is refused by the gate — the commit
/// itself never gets this far, because <c>DescribeUncommittableLeaf</c> refuses the leaf first.
/// </summary>
[Fact]
public void BuildTagConfig_Sql_invents_no_selector_when_the_session_stated_no_candidate()
{
var json = RawBrowseCommitMapper.BuildTagConfig(
DriverTypeNames.Sql, "setpoint", SqlFields(table: "Settings", column: "setpoint", timestampColumn: null));
var o = JsonNode.Parse(json)!.AsObject();
o["table"]!.GetValue<string>().ShouldBe("Settings");
o["columnName"]!.GetValue<string>().ShouldBe("setpoint");
o.ContainsKey("rowSelector").ShouldBeFalse("an empty or fabricated selector is worse than none");
TagConfigValidator.Validate(DriverTypeNames.Sql, json).ShouldNotBeNullOrEmpty();
SqlEquipmentTagParser.TryParse(json, "p", out _).ShouldBeFalse();
}
/// <summary>A blank stated candidate is treated as none, not written through as a blank selector.</summary>
[Fact]
public void BuildTagConfig_Sql_treats_a_blank_stated_candidate_as_none()
{
var json = RawBrowseCommitMapper.BuildTagConfig(
DriverTypeNames.Sql, "setpoint", SqlFields(timestampColumn: " "));
JsonNode.Parse(json)!.AsObject().ContainsKey("rowSelector").ShouldBeFalse();
}
[Fact]
public void DescribeUncommittableLeaf_refuses_a_sql_leaf_whose_table_has_no_date_time_column()
{
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf(
DriverTypeNames.Sql, "setpoint", SqlFields(table: "Settings", timestampColumn: null));
error.ShouldNotBeNull();
error!.ShouldContain("setpoint");
error.ShouldContain("Settings"); // names the table, not just the leaf
error.ShouldContain("whereColumn"); // and names the fix
}
[Fact]
public void DescribeUncommittableLeaf_refuses_a_sql_leaf_whose_address_was_never_stated()
{
var error = RawBrowseCommitMapper.DescribeUncommittableLeaf(
DriverTypeNames.Sql, "oven_temp", addressFields: null);
error.ShouldNotBeNull();
error!.ShouldContain("oven_temp");
}
/// <summary>The acceptance control — a stated candidate makes the leaf committable.</summary>
[Fact]
public void DescribeUncommittableLeaf_accepts_a_sql_leaf_carrying_a_stated_candidate()
=> RawBrowseCommitMapper
.DescribeUncommittableLeaf(DriverTypeNames.Sql, "oven_temp", SqlFields())
.ShouldBeNull();
/// <summary>
/// The two refusals are one seam and must stay in agreement: a leaf the mapper can build a
/// gate-passing blob for must be committable, and one it cannot must be refused. Getting this
/// backwards either blocks a working commit or lets an unreadable tag through.
/// </summary>
[Theory]
[InlineData("recorded", false)]
[InlineData(null, true)]
public void Sql_refusal_and_the_emitted_blob_agree(string? timestampColumn, bool expectRefused)
{
var fields = SqlFields(timestampColumn: timestampColumn);
var refused = RawBrowseCommitMapper
.DescribeUncommittableLeaf(DriverTypeNames.Sql, "oven_temp", fields) is not null;
var blobRejected = TagConfigValidator.Validate(
DriverTypeNames.Sql,
RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "oven_temp", fields)) is not null;
refused.ShouldBe(expectRefused);
blobRejected.ShouldBe(expectRefused);
}
} }
@@ -8,6 +8,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip; using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus; using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
using ZB.MOM.WW.OtOpcUa.Driver.S7; using ZB.MOM.WW.OtOpcUa.Driver.S7;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
@@ -322,13 +323,13 @@ public sealed class RawTagConfigGateTests
/// <see cref="RawBrowseCommitMapper.BuildTagConfig"/> emits must survive the gate — otherwise the /// <see cref="RawBrowseCommitMapper.BuildTagConfig"/> emits must survive the gate — otherwise the
/// commit would author rows the import refuses. /// commit would author rows the import refuses.
/// <para> /// <para>
/// <b><c>Sql</c> is deliberately absent, and that is a FINDING, not an oversight.</b> Its branch /// <b><c>Sql</c> is here now (Gitea #552), and it is the one driver whose leaf carries stated
/// emits <c>{"model":"WideRow","table":…,"columnName":…}</c> with no <c>rowSelector</c>, which /// address fields it cannot be built without</b> — a wide-row tag needs the table, the column
/// <c>SqlTagConfigModel.Validate</c> refuses and <c>SqlEquipmentTagParser</c> refuses too — so a /// AND which row, so the theory supplies the fields its browse session states rather than the
/// browse-committed Sql tag has never been readable, it merely failed later (at the deploy gate, /// bare node id every single-reference driver binds by. The no-candidate half of that branch —
/// or silently at the driver) instead of at commit. Pinning today's behaviour either way would /// a table with no date/time column, where the row selector genuinely cannot be derived from
/// encode a defect; it is reported for its own fix (the browse session should state a row /// schema — is pinned separately by
/// selector, or Sql should commit through the typed editor). /// <see cref="A_browse_committed_Sql_config_is_refused_when_the_table_has_no_timestamp"/>.
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="driverType">The browsable driver type.</param> /// <param name="driverType">The browsable driver type.</param>
@@ -341,14 +342,60 @@ public sealed class RawTagConfigGateTests
[InlineData(DriverTypeNames.MTConnect)] [InlineData(DriverTypeNames.MTConnect)]
[InlineData(DriverTypeNames.Mqtt)] [InlineData(DriverTypeNames.Mqtt)]
[InlineData(DriverTypeNames.Galaxy)] [InlineData(DriverTypeNames.Galaxy)]
[InlineData(DriverTypeNames.Sql)]
public void A_browse_committed_config_passes_the_gate(string driverType) public void A_browse_committed_config_passes_the_gate(string driverType)
{ {
var tagConfig = RawBrowseCommitMapper.BuildTagConfig(driverType, "SomeAddress"); var tagConfig = RawBrowseCommitMapper.BuildTagConfig(
driverType, "SomeAddress", BrowseFieldsFor(driverType));
TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull( TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull(
$"browse-commit for {driverType} must not author a config the import refuses"); $"browse-commit for {driverType} must not author a config the import refuses");
} }
/// <summary>
/// The stated address fields a browse session supplies for this driver's leaf, or <c>null</c> for
/// every driver whose address IS the node id.
/// </summary>
/// <param name="driverType">The browsable driver type.</param>
/// <returns>The stated fields, or null.</returns>
private static IReadOnlyDictionary<string, string>? BrowseFieldsFor(string driverType) =>
string.Equals(driverType, DriverTypeNames.Sql, StringComparison.OrdinalIgnoreCase)
? new Dictionary<string, string>(StringComparer.Ordinal)
{
[SqlBrowseAddressFields.Schema] = "dbo",
[SqlBrowseAddressFields.Table] = "Readings",
[SqlBrowseAddressFields.ColumnName] = "SomeAddress",
[SqlBrowseAddressFields.TopByTimestamp] = "recorded",
}
: null;
/// <summary>
/// The other half of #552, kept explicit rather than papered over: a wide-row tag must say WHICH row
/// it reads, and on a table with no date/time column the only remaining selector is a
/// <c>whereColumn</c>/<c>whereValue</c> pair whose value is <em>data</em>. No browse can know it, so
/// the mapper invents nothing and the gate refuses the blob.
/// <para>The operator never meets this as a bare gate error: the browse modal calls
/// <see cref="RawBrowseCommitMapper.DescribeUncommittableLeaf"/> first, which names the table and the
/// fix. Both are asserted here so deleting either leaves this red.</para>
/// </summary>
[Fact]
public void A_browse_committed_Sql_config_is_refused_when_the_table_has_no_timestamp()
{
var fields = new Dictionary<string, string>(StringComparer.Ordinal)
{
[SqlBrowseAddressFields.Schema] = "dbo",
[SqlBrowseAddressFields.Table] = "Settings",
[SqlBrowseAddressFields.ColumnName] = "setpoint",
};
RawBrowseCommitMapper.DescribeUncommittableLeaf(DriverTypeNames.Sql, "setpoint", fields)
.ShouldNotBeNullOrEmpty();
TagConfigValidator
.Validate(DriverTypeNames.Sql, RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.Sql, "setpoint", fields))
.ShouldNotBeNullOrEmpty();
}
/// <summary>Sanity: the enums the three new rules read are the driver's own, not a local copy.</summary> /// <summary>Sanity: the enums the three new rules read are the driver's own, not a local copy.</summary>
[Fact] [Fact]
public void The_new_rules_read_the_drivers_own_enums() public void The_new_rules_read_the_drivers_own_enums()