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;
///
/// The guard 's fallback comment cites (Gitea #555):
/// every driver type in either gets a dedicated branch in
/// BuildTagConfig, or appears in with a written reason.
///
/// Why the generic key is a defect for a browsable driver. The fallback writes
/// {"address": "<leaf>"}. A driver whose typed editor reads a different key — MTConnect's
/// fullName, OPC UA Client's nodeId, Sql's table/columnName/rowSelector
/// — then opens that committed tag with an EMPTY address field and blanks it on save. That is
/// exactly why the MTConnect branch was added, and Sql shipped with the same bug unnoticed
/// (deferment.md G-6) because the fallback's comment asserted "every BROWSABLE driver is handled
/// above" while SqlDriverBrowser was already registered.
///
///
/// Enumeration, not InlineData. RawTagConfigGateTests 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 , so a newly-registered driver is triaged the first time
/// the suite runs — the idiom DriverFormMapParityTests / DriverDispatchMapParityTests use.
///
///
/// The only guard over this seam (Gitea #558). DriverDispatchMapParityTests briefly held a
/// second one, enumerating the same mapper against its own hand-authored
/// NonBrowsableDriverTypes. Two lists for one question can only drift, so it was retired into
/// this class: 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
/// .
///
///
///
///
/// Browsability derivation, and its residual trust. Production decides browsability in
/// BrowserSessionService.CanBrowse, which is a disjunction of two arms:
///
///
/// -
/// a bespoke registered for the driver type — derived here for
/// real, by running the production AddAdminUI() registration and reading back every
/// registered browser's . 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.
///
/// -
/// the universal DiscoveryDriverBrowser, whose CanBrowse constructs the driver
/// through IDriverFactory and asks whether it is
/// ITagDiscovery { SupportsOnlineDiscovery: true }. That arm is not derivable from this
/// assembly: the AdminUI references driver Contracts and Browser projects, never
/// the runtime drivers, and IDriverFactory 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.
///
///
///
/// So the universal arm is covered the only honest way available here: by requiring every 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.
/// then mechanically refutes any exemption the derived arm contradicts — which is the failure mode that
/// actually occurred with Sql.
///
///
[Trait("Category", "Unit")]
public sealed class RawBrowseCommitMapperParityTests
{
/// The browse leaf's reference, as the commit path passes it (BrowseNode.NodeId).
private const string Reference = "Speed";
///
/// THE #555 guard, over the arm that can be derived: a driver type with a registered bespoke
/// browser must commit to a typed blob.
///
[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.");
}
///
/// 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.
///
[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.");
}
///
/// 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.
///
[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.");
}
///
/// The other direction: an exemption must still DESCRIBE the mapper. A driver that has since gained
/// a typed BuildTagConfig 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 missing from the list.
///
///
/// Inherited from DriverDispatchMapParityTests'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
/// is a live description of what the mapper does today, not an
/// archived one.
///
[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.");
}
///
/// Every registered browser is indexed under a value, exactly.
/// BrowserSessionService 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.
///
///
/// Added with Gitea #558, alongside repointing OpcUaClientDriverBrowser and
/// GalaxyDriverBrowser off string literals ("OpcUaClient" / "GalaxyMxGateway")
/// 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
/// DriverTypeNamesGuardTests, 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.
///
/// Compared ORDINALLY, not case-insensitively: BrowserSessionService's index and the
/// persisted DriverType must agree on the exact string, and "differs only in case" is
/// precisely the TwinCat/TwinCAT drift exists to end.
///
///
[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.");
}
/// An exemption for a driver type that no longer exists is stale bookkeeping.
[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 ------------------------------------------------------------------
///
/// Runs the production AddAdminUI() registration and reads back the driver type of every
/// it registers — the same set BrowserSessionService indexes.
///
/// The driver types with a bespoke browse session, as production registers them.
///
/// Resolved through a real provider rather than read off the ServiceDescriptors, because
/// is an instance member — GalaxyDriverBrowser takes a
/// required , so the two collaborators it needs are supplied here.
/// Nothing in the graph is exercised beyond construction; no browse is opened.
///
private static IReadOnlyList RegisteredBespokeBrowserDriverTypes()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton();
services.AddAdminUI();
using var provider = services.BuildServiceProvider();
return [.. provider.GetServices().Select(b => b.DriverType)];
}
///
/// Does the mapper drop this driver type onto the unknown-driver fallback — the single-key
/// {"address": …} blob WriteSingleKey writes?
///
/// The driver type to map a leaf for.
/// when the commit produced the generic fallback blob.
///
/// 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 address 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.
///
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() == Reference;
}
///
/// The address fields a browse session states for a driver whose binding is a TUPLE rather than a
/// single reference string; for every driver whose address IS the leaf's node id.
///
/// The driver type being mapped.
/// The stated fields, or when the driver states none.
///
/// 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 table its branch deliberately returns the generic blob,
/// so a Sql entry here is what makes its typed branch observable at all.
///
/// The browsed native dataType (Gitea #536) is deliberately not restated — it changes a field
/// inside an already-typed blob, never which key the address lands under, and
/// RawBrowseCommitMapperTests pins it per driver.
///
///
private static IReadOnlyDictionary? StatedAddressFields(string driverType) => driverType switch
{
DriverTypeNames.Mqtt => new Dictionary(StringComparer.Ordinal)
{
[MqttTagConfigKeys.GroupId] = "OtOpcUaSim",
[MqttTagConfigKeys.EdgeNodeId] = "EdgeA",
[MqttTagConfigKeys.MetricName] = Reference,
},
DriverTypeNames.Sql => new Dictionary(StringComparer.Ordinal)
{
[SqlBrowseAddressFields.Schema] = "dbo",
[SqlBrowseAddressFields.Table] = "Readings",
[SqlBrowseAddressFields.ColumnName] = Reference,
[SqlBrowseAddressFields.TopByTimestamp] = "SampledAt",
},
_ => null,
};
/// One driver type deliberately left on the generic address key, and why.
/// The exempted value.
/// Why the generic key is the right commit for it — a claim, open to refutation.
private sealed record GenericKeyExemption(string DriverType, string Reason);
///
/// Driver types with no browse path at all, for which the generic address key is correct.
/// Adding an entry here is a claim that the driver cannot be browsed — by a bespoke
/// (mechanically refuted by
/// ) nor by
/// the universal discovery browser (which needs the driver to be
/// ITagDiscovery { SupportsOnlineDiscovery: true }). If it can be browsed, write a branch instead.
///
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."),
];
///
/// An that resolves every secret to absent, for constructing
/// GalaxyDriverBrowser. 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.
///
private sealed class AbsentSecretResolver : ISecretResolver
{
///
public Task GetAsync(SecretName name, CancellationToken ct) => Task.FromResult(null);
}
}