Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawBrowseCommitMapperParityTests.cs
T
Joseph Doherty 17130d9c82 test(parity): consolidate the browse-commit guard onto one list; key browsers off DriverTypeNames (#558)
Two guards enumerated the same RawBrowseCommitMapper.BuildTagConfig seam against
two hand-authored lists, which can only drift. The older one's list was also
misnamed: DriverDispatchMapParityTests.NonBrowsableDriverTypes really meant
"types that produce the generic key" — AbLegacy is genuinely non-browsable yet
correctly absent from it, because it has a typed branch.

Retire the older fact. RawBrowseCommitMapperParityTests.GenericKeyExemptions is
now the single hand-authored list for this seam: named for what it holds, one
written reason per entry, and checked against a DI-derived browsable set rather
than a second list. The one assertion the retired fact made that nothing here
covered — set EQUALITY, i.e. an exemption must still DESCRIBE the mapper — moves
across as Every_generic_key_exemption_still_commits_to_the_generic_key. Without
it, a driver that gained a typed branch would keep a silently inert exemption.

Item 2: OpcUaClientDriverBrowser and GalaxyDriverBrowser returned string
literals ("OpcUaClient", "GalaxyMxGateway") where Sql/Mqtt use DriverTypeNames.
The values matched, and nothing guarded that. Repoint both at the constants —
DriverTypeNames.Galaxy carries the identical value — and add
Every_registered_browser_is_keyed_by_a_declared_driver_type_name over the same
AddAdminUI() read-back. Compared ORDINALLY: BrowserSessionService indexes on the
exact persisted string, and case-only drift (TwinCat/TwinCAT) is what
DriverTypeNames exists to end. Browser projects are out of reach of
DriverTypeNamesGuardTests, which pins the driver FACTORIES' names.

Falsified, four ways, each red naming the offender and green on revert:
  - #555 re-run: disabling the Sql branch reddens the two enumeration facts
    ("...no dedicated BuildTagConfig branch...: Sql"), the same catch the
    retired fact made.
  - giving Modbus a typed branch reddens ONLY the new stale-exemption fact,
    proving it is not redundant with the four that were already there.
  - SqlDriverBrowser.DriverType => "SQL" and Galaxy => "GalaxyMXGateway" each
    redden the new browser-key guard by name — case-only typos, which an
    OrdinalIgnoreCase compare would have waved through.
2026-08-10 02:56:05 -04:00

347 lines
20 KiB
C#

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>
/// <para>
/// <b>The only guard over this seam (Gitea #558).</b> <c>DriverDispatchMapParityTests</c> briefly held a
/// second one, enumerating the same mapper against its own hand-authored
/// <c>NonBrowsableDriverTypes</c>. Two lists for one question can only drift, so it was retired into
/// this class: <see cref="GenericKeyExemptions"/> is now the single hand-authored list for browse-commit,
/// and the one assertion the old fact made that nothing here covered — that an exemption still describes
/// the mapper — came with it as
/// <see cref="Every_generic_key_exemption_still_commits_to_the_generic_key"/>.
/// </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>
/// The other direction: an exemption must still DESCRIBE the mapper. A driver that has since gained
/// a typed <c>BuildTagConfig</c> branch no longer reaches the generic key, so its exemption is a
/// stale claim — and worse, a silently inert one, since the enumeration above only ever flags
/// drivers that are <em>missing</em> from the list.
/// </summary>
/// <remarks>
/// Inherited from <c>DriverDispatchMapParityTests</c>'s retired browse-commit fact, which asserted
/// set EQUALITY against its own hand-authored list and so covered this direction as a side effect
/// (Gitea #558). Consolidating onto one list would have dropped the assertion had it not moved here.
/// Keeping it also keeps the list honest as a piece of documentation: every reason written in
/// <see cref="GenericKeyExemptions"/> is a live description of what the mapper does today, not an
/// archived one.
/// </remarks>
[Fact]
public void Every_generic_key_exemption_still_commits_to_the_generic_key()
{
var superseded = GenericKeyExemptions
.Where(e => !CommitsToTheGenericAddressKey(e.DriverType))
.OrderBy(e => e.DriverType, StringComparer.Ordinal)
.ToList();
superseded.ShouldBeEmpty(
"these driver types are exempted from needing a typed branch, but RawBrowseCommitMapper now "
+ $"gives them one: {string.Join(", ", superseded.Select(e => e.DriverType))}. The exemption is "
+ "stale — delete it, so the list keeps meaning what it says.");
}
/// <summary>
/// Every registered browser is indexed under a <see cref="DriverTypeNames"/> value, exactly.
/// <c>BrowserSessionService</c> looks a browser up by the driver type persisted on the device, so a
/// browser whose key is anything else is registered but unreachable — the picker reports "no browser
/// for this driver" and the operator falls back to typing the address by hand.
/// </summary>
/// <remarks>
/// Added with Gitea #558, alongside repointing <c>OpcUaClientDriverBrowser</c> and
/// <c>GalaxyDriverBrowser</c> off string literals (<c>"OpcUaClient"</c> / <c>"GalaxyMxGateway"</c>)
/// onto the constants. The values matched, so nothing was broken — but nothing prevented the next
/// browser from hand-writing its key either, and a browser project cannot be reached by
/// <c>DriverTypeNamesGuardTests</c>, which pins the driver FACTORIES' names rather than the
/// browsers'. This is the seam where the two meet: the same DI read-back the guards above use
/// already has every browser instance in hand.
/// <para>
/// Compared ORDINALLY, not case-insensitively: <c>BrowserSessionService</c>'s index and the
/// persisted <c>DriverType</c> must agree on the exact string, and "differs only in case" is
/// precisely the <c>TwinCat</c>/<c>TwinCAT</c> drift <see cref="DriverTypeNames"/> exists to end.
/// </para>
/// </remarks>
[Fact]
public void Every_registered_browser_is_keyed_by_a_declared_driver_type_name()
{
var declared = DriverTypeNames.All.ToHashSet(StringComparer.Ordinal);
var unreachable = RegisteredBespokeBrowserDriverTypes()
.Where(t => !declared.Contains(t))
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
unreachable.ShouldBeEmpty(
"these IDriverBrowser registrations are keyed by a string that is not a DriverTypeNames member, "
+ $"so BrowserSessionService can never match a device to them: {string.Join(", ", unreachable)}. "
+ "Use the DriverTypeNames constant.");
}
/// <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);
}
}