merge: #555 — RawBrowseCommitMapperParityTests exists now: bespoke-browser types derived from the real AddAdminUI registration must be typed; generic-key fallthrough demands a documented exemption

This commit is contained in:
Joseph Doherty
2026-08-10 01:38:50 -04:00
2 changed files with 281 additions and 3 deletions
@@ -267,9 +267,14 @@ public static class RawBrowseCommitMapper
return BuildSqlTagConfig(address, addressFields);
// Unknown/flat-address driver (e.g. Modbus is not browsable): record the reference under a generic
// "address" key so nothing is lost. Every BROWSABLE driver is handled above — guarded by
// RawBrowseCommitMapperParityTests, which enumerates DriverTypeNames.All rather than trusting
// this comment.
// "address" key so nothing is lost. RawBrowseCommitMapperParityTests enumerates DriverTypeNames.All
// and holds this line to two claims rather than to the assertion the comment used to make:
// - a driver type with a bespoke IDriverBrowser registered by AddAdminUI() — read back from the
// real registration, not a list — must NOT reach here; and
// - any type that does reach here must be named in that test's GenericKeyExemptions with a reason.
// The universal DiscoveryDriverBrowser's arm is not derivable from the AdminUI assembly (it needs
// the runtime drivers + the Host's IDriverFactory), so it is covered by the exemption list rather
// than mechanically — see the test's remarks for the residual trust that leaves (Gitea #555).
return WriteSingleKey("address", address);
}
@@ -0,0 +1,273 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// The guard <see cref="RawBrowseCommitMapper.BuildTagConfig"/>'s fallback comment cites (Gitea #555):
/// every driver type in <see cref="DriverTypeNames.All"/> either gets a <b>dedicated</b> branch in
/// <c>BuildTagConfig</c>, or appears in <see cref="GenericKeyExemptions"/> with a written reason.
/// <para>
/// <b>Why the generic key is a defect for a browsable driver.</b> The fallback writes
/// <c>{"address": "&lt;leaf&gt;"}</c>. A driver whose typed editor reads a different key — MTConnect's
/// <c>fullName</c>, OPC UA Client's <c>nodeId</c>, Sql's <c>table</c>/<c>columnName</c>/<c>rowSelector</c>
/// — then opens that committed tag with an EMPTY address field and <em>blanks it on save</em>. That is
/// exactly why the MTConnect branch was added, and Sql shipped with the same bug unnoticed
/// (<c>deferment.md</c> G-6) because the fallback's comment asserted "every BROWSABLE driver is handled
/// above" while <c>SqlDriverBrowser</c> was already registered.
/// </para>
/// <para>
/// <b>Enumeration, not InlineData.</b> <c>RawTagConfigGateTests</c> covers the same mapper through
/// hand-written per-driver cases, which cannot notice a driver type that nobody wrote a case for. This
/// class walks <see cref="DriverTypeNames.All"/>, so a newly-registered driver is triaged the first time
/// the suite runs — the idiom <c>DriverFormMapParityTests</c> / <c>DriverDispatchMapParityTests</c> use.
/// </para>
/// </summary>
/// <remarks>
/// <para>
/// <b>Browsability derivation, and its residual trust.</b> Production decides browsability in
/// <c>BrowserSessionService.CanBrowse</c>, which is a disjunction of two arms:
/// </para>
/// <list type="number">
/// <item>
/// a <b>bespoke</b> <see cref="IDriverBrowser"/> registered for the driver type — derived here for
/// real, by running the production <c>AddAdminUI()</c> registration and reading back every
/// registered browser's <see cref="IDriverBrowser.DriverType"/>. Nothing is hand-listed, so a
/// driver that gains a bespoke browser starts demanding a branch on the next run, and one that
/// loses its browser stops.
/// </item>
/// <item>
/// the <b>universal</b> <c>DiscoveryDriverBrowser</c>, whose <c>CanBrowse</c> constructs the driver
/// through <c>IDriverFactory</c> and asks whether it is
/// <c>ITagDiscovery { SupportsOnlineDiscovery: true }</c>. That arm is <b>not derivable from this
/// assembly</b>: the AdminUI references driver <i>Contracts</i> and <i>Browser</i> projects, never
/// the runtime drivers, and <c>IDriverFactory</c> is composed in the Host — so the types that answer
/// "yes" (AbCip, TwinCAT, FOCAS, MTConnect) have no representative in this project's output at all.
/// Dragging the Host + every runtime driver into an AdminUI unit-test project to reach them would
/// trade this residual trust for a project-reference list that is just as hand-authored.
/// </item>
/// </list>
/// <para>
/// So the universal arm is covered the only honest way available here: by requiring <b>every</b> driver
/// type to be typed or explicitly exempted, and by making each exemption a positive claim ("this driver
/// cannot be browsed") that a reader can falsify. <see cref="No_generic_key_exemption_covers_a_driver_type_that_has_a_registered_browser"/>
/// then mechanically refutes any exemption the derived arm contradicts — which is the failure mode that
/// actually occurred with Sql.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class RawBrowseCommitMapperParityTests
{
/// <summary>The browse leaf's reference, as the commit path passes it (<c>BrowseNode.NodeId</c>).</summary>
private const string Reference = "Speed";
/// <summary>
/// THE #555 guard, over the arm that can be derived: a driver type with a registered bespoke
/// browser must commit to a typed blob.
/// </summary>
[Fact]
public void Every_registered_bespoke_browser_driver_type_gets_a_typed_tag_config()
{
var browsable = RegisteredBespokeBrowserDriverTypes();
// Falsifiability floor: an empty set would make this pass while asserting nothing, which is how a
// broken registration read would look. Four browsers are registered today (OpcUaClient, Galaxy,
// Sql, Mqtt).
browsable.ShouldNotBeEmpty(
"no IDriverBrowser came back from AddAdminUI() — the registration read is broken, not the mapper");
var untyped = browsable
.Where(CommitsToTheGenericAddressKey)
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
untyped.ShouldBeEmpty(
"these driver types have a registered browse session but no dedicated RawBrowseCommitMapper."
+ "BuildTagConfig branch, so a browse-committed tag lands under the generic \"address\" key: "
+ $"{string.Join(", ", untyped)}. Their typed tag editor reads a different key, so the tag "
+ "opens with an empty address and is blanked on save (deferment.md G-6). Add a branch.");
}
/// <summary>
/// The enumeration the fallback's comment claims: no driver type reaches the generic key by
/// accident. Covers the universal-browse arm, which cannot be derived from this assembly.
/// </summary>
[Fact]
public void Every_declared_driver_type_is_typed_or_carries_a_documented_generic_key_exemption()
{
var exempt = GenericKeyExemptions.Select(e => e.DriverType).ToHashSet(StringComparer.OrdinalIgnoreCase);
var undocumented = DriverTypeNames.All
.Where(t => CommitsToTheGenericAddressKey(t) && !exempt.Contains(t))
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
undocumented.ShouldBeEmpty(
"these driver types commit to the generic \"address\" key with nothing recorded about why: "
+ $"{string.Join(", ", undocumented)}. If the driver can be browsed — a bespoke IDriverBrowser, "
+ "or an ITagDiscovery driver the universal DiscoveryDriverBrowser can open — give it a "
+ "BuildTagConfig branch. If it genuinely cannot, add it to GenericKeyExemptions with the "
+ "reason, so the next reader inherits the argument instead of the silence.");
}
/// <summary>
/// Refutes an exemption the derivable arm contradicts. This is the check that would have caught Sql:
/// a registered browser is proof the driver IS browsable, whatever the exemption claims.
/// </summary>
[Fact]
public void No_generic_key_exemption_covers_a_driver_type_that_has_a_registered_browser()
{
var browsable = RegisteredBespokeBrowserDriverTypes().ToHashSet(StringComparer.OrdinalIgnoreCase);
var contradicted = GenericKeyExemptions
.Where(e => browsable.Contains(e.DriverType))
.OrderBy(e => e.DriverType, StringComparer.Ordinal)
.ToList();
contradicted.ShouldBeEmpty(
"these exemptions claim a driver cannot be browsed, but AdminUI registers an IDriverBrowser for "
+ $"it: {string.Join("; ", contradicted.Select(e => $"{e.DriverType} — \"{e.Reason}\""))}. "
+ "Remove the exemption and give the driver a BuildTagConfig branch.");
}
/// <summary>An exemption for a driver type that no longer exists is stale bookkeeping.</summary>
[Fact]
public void Every_generic_key_exemption_names_a_declared_driver_type()
{
var declared = DriverTypeNames.All.ToHashSet(StringComparer.OrdinalIgnoreCase);
var stale = GenericKeyExemptions
.Select(e => e.DriverType)
.Where(t => !declared.Contains(t))
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
stale.ShouldBeEmpty($"GenericKeyExemptions names undeclared driver types: {string.Join(", ", stale)}");
}
// ---- derivation + fixtures ------------------------------------------------------------------
/// <summary>
/// Runs the production <c>AddAdminUI()</c> registration and reads back the driver type of every
/// <see cref="IDriverBrowser"/> it registers — the same set <c>BrowserSessionService</c> indexes.
/// </summary>
/// <returns>The driver types with a bespoke browse session, as production registers them.</returns>
/// <remarks>
/// Resolved through a real provider rather than read off the <c>ServiceDescriptor</c>s, because
/// <see cref="IDriverBrowser.DriverType"/> is an instance member — <c>GalaxyDriverBrowser</c> takes a
/// required <see cref="ISecretResolver"/>, so the two collaborators it needs are supplied here.
/// Nothing in the graph is exercised beyond construction; no browse is opened.
/// </remarks>
private static IReadOnlyList<string> RegisteredBespokeBrowserDriverTypes()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ISecretResolver, AbsentSecretResolver>();
services.AddAdminUI();
using var provider = services.BuildServiceProvider();
return [.. provider.GetServices<IDriverBrowser>().Select(b => b.DriverType)];
}
/// <summary>
/// Does the mapper drop this driver type onto the unknown-driver fallback — the single-key
/// <c>{"address": …}</c> blob <c>WriteSingleKey</c> writes?
/// </summary>
/// <param name="driverType">The driver type to map a leaf for.</param>
/// <returns><see langword="true"/> when the commit produced the generic fallback blob.</returns>
/// <remarks>
/// Judged on the produced JSON rather than on which branch ran, because the JSON is what the typed
/// editor later reads. A driver whose own model happens to emit only <c>address</c> would read back
/// identically and is correctly not flagged — every typed model in the tree emits more keys than
/// that, so the two shapes stay distinguishable in practice.
/// </remarks>
private static bool CommitsToTheGenericAddressKey(string driverType)
{
var json = RawBrowseCommitMapper.BuildTagConfig(driverType, Reference, StatedAddressFields(driverType));
var o = JsonNode.Parse(json)!.AsObject();
return o.Count == 1
&& o.TryGetPropertyValue("address", out var value)
&& value?.GetValue<string>() == Reference;
}
/// <summary>
/// The address fields a browse session states for a driver whose binding is a TUPLE rather than a
/// single reference string; <see langword="null"/> for every driver whose address IS the leaf's node id.
/// </summary>
/// <param name="driverType">The driver type being mapped.</param>
/// <returns>The stated fields, or <see langword="null"/> when the driver states none.</returns>
/// <remarks>
/// Keyed off the drivers' own published key constants, not string literals, and null is the default
/// ON PURPOSE: a future tuple-address driver that lands with no entry here maps from nothing, falls
/// to the generic key, and fails the guard by name — which is the direction to fail in. Sql is the
/// proof that matters: with no stated <c>table</c> its branch deliberately returns the generic blob,
/// so a Sql entry here is what makes its typed branch observable at all.
/// <para>
/// The browsed native <c>dataType</c> (Gitea #536) is deliberately not restated — it changes a field
/// inside an already-typed blob, never which key the address lands under, and
/// <c>RawBrowseCommitMapperTests</c> pins it per driver.
/// </para>
/// </remarks>
private static IReadOnlyDictionary<string, string>? StatedAddressFields(string driverType) => driverType switch
{
DriverTypeNames.Mqtt => new Dictionary<string, string>(StringComparer.Ordinal)
{
[MqttTagConfigKeys.GroupId] = "OtOpcUaSim",
[MqttTagConfigKeys.EdgeNodeId] = "EdgeA",
[MqttTagConfigKeys.MetricName] = Reference,
},
DriverTypeNames.Sql => new Dictionary<string, string>(StringComparer.Ordinal)
{
[SqlBrowseAddressFields.Schema] = "dbo",
[SqlBrowseAddressFields.Table] = "Readings",
[SqlBrowseAddressFields.ColumnName] = Reference,
[SqlBrowseAddressFields.TopByTimestamp] = "SampledAt",
},
_ => null,
};
/// <summary>One driver type deliberately left on the generic <c>address</c> key, and why.</summary>
/// <param name="DriverType">The exempted <see cref="DriverTypeNames"/> value.</param>
/// <param name="Reason">Why the generic key is the right commit for it — a claim, open to refutation.</param>
private sealed record GenericKeyExemption(string DriverType, string Reason);
/// <summary>
/// Driver types with no browse path at all, for which the generic <c>address</c> key is correct.
/// Adding an entry here is a claim that the driver cannot be browsed — by a bespoke
/// <see cref="IDriverBrowser"/> (mechanically refuted by
/// <see cref="No_generic_key_exemption_covers_a_driver_type_that_has_a_registered_browser"/>) nor by
/// the universal discovery browser (which needs the driver to be
/// <c>ITagDiscovery { SupportsOnlineDiscovery: true }</c>). If it can be browsed, write a branch instead.
/// </summary>
private static readonly GenericKeyExemption[] GenericKeyExemptions =
[
new(DriverTypeNames.Modbus,
"Register addresses are numeric coordinates in a fixed map, not a namespace anything can "
+ "enumerate: ModbusDriver implements no ITagDiscovery and has no bespoke browser. The generic "
+ "key is also the key ModbusTagConfigModel reads, so nothing is lost even if a leaf arrives."),
new(DriverTypeNames.Calculation,
"A pseudo-driver computing over other tags' RawPaths — there is no device to browse, and its "
+ "binding is a scriptId rather than an address."),
];
/// <summary>
/// An <see cref="ISecretResolver"/> that resolves every secret to absent, for constructing
/// <c>GalaxyDriverBrowser</c>. Nothing here opens a session, so no secret is ever requested — and
/// resolving to absent means a browser that started requesting one fails closed rather than
/// silently succeeding against a fake value.
/// </summary>
private sealed class AbsentSecretResolver : ISecretResolver
{
/// <inheritdoc />
public Task<string?> GetAsync(SecretName name, CancellationToken ct) => Task.FromResult<string?>(null);
}
}