diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx index c5a50698..0d9e5f18 100644 --- a/ZB.MOM.WW.OtOpcUa.slnx +++ b/ZB.MOM.WW.OtOpcUa.slnx @@ -32,6 +32,7 @@ + diff --git a/docs/plans/2026-07-15-sql-poll-driver-design.md b/docs/plans/2026-07-15-sql-poll-driver-design.md index 0aa81f58..62a13701 100644 --- a/docs/plans/2026-07-15-sql-poll-driver-design.md +++ b/docs/plans/2026-07-15-sql-poll-driver-design.md @@ -62,7 +62,8 @@ All `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors`, mat |---|---| | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/` | Options DTOs, `SqlProvider` enum, tag-model records, `SqlEquipmentTagParser`. **Zero transport deps** (refs only `Core.Abstractions`) so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`. | | `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/` | `SqlDriver` (runtime capabilities), `ISqlDialect` + `SqlServerDialect`, `SqlPollReader`, factory extensions. **Owns the `Microsoft.Data.SqlClient` package ref.** | -| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is a deliberate deviation from `OpcUaClient.Browser` (which refs only its Contracts and owns the OPC UA client packages itself): the browser still opens its own transient connection. The resulting **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. Recorded here deliberately so nobody "fixes" the deviation later without this context. | +| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Sql.Dialect`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). The browser opens its own transient connection. The **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. | +| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/` | `ISqlDialect` + `SqlServerDialect`, assembly-split out of `Driver.Sql` (namespace unchanged). **Added by Gitea #526**, which corrected the row above: it used to say the browser referenced the runtime **`Driver.Sql`**, called that "a deliberate deviation from `OpcUaClient.Browser`", and asked that nobody "fix" it without reading this context. The *sharing* premise was right and survives; the conclusion that sharing required the runtime reference was not. The AdminUI references the browser, so that edge put the whole SQL driver runtime — and `ZB.MOM.WW.OtOpcUa.Core` through it — into the AdminUI's build/deploy graph, invisibly (measured before and after: both DLLs present, then absent). The dialect depends on nothing in `Driver.Sql`, so it moved to this leaf, referenced by the runtime driver and the browser alike. It cannot live in `.Contracts`: `ISqlDialect` exposes `DbProviderFactory` and `SqlServerDialect` returns `SqlClientFactory.Instance`, and `.Contracts` is deliberately provider-free — the same shape as `Driver.Mqtt.Transport`. Guarded by `DriverBrowserLayeringTests`, which scans every `*.Browser` csproj in the tree. | | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/` | Unit suite (SQLite fixture, §9). | | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/` | Env-gated integration suite (central SQL Server, §9). | | `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/` | Browser unit tests (SQLite `PRAGMA` dialect). | diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs index bd43f089..2b0bd7a1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/SqlDriverBrowser.cs @@ -4,6 +4,7 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; 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; @@ -100,12 +101,14 @@ public sealed class SqlDriverBrowser : IDriverBrowser /// /// - /// Sourced from , the driver's interim local constant, rather - /// than from DriverTypeNames — the shared constant set is added by the driver-factory task, - /// because adding a member there reddens DriverTypeNamesGuardTests until a factory is - /// registered against it. Repointing that one constant repoints this too. + /// Sourced from the shared set, which is what the driver's own + /// SqlDriver.DriverTypeName resolves to. This used to read that interim local constant, and + /// the comment here said so; repointing it was deferred until a factory was registered against + /// (else DriverTypeNamesGuardTests goes red). One exists + /// now, and reading the shared constant is also what lets this browser stay off the runtime driver + /// — SqlDriver is the only type it still needed from there (Gitea #526). /// - public string DriverType => SqlDriver.DriverTypeName; + public string DriverType => DriverTypeNames.Sql; /// The environment variable a connectionStringRef resolves through. /// The authored reference name. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj index daa01255..127b7caa 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj @@ -11,15 +11,34 @@ - - + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs similarity index 97% rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs index 13f26329..31dae6ef 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ISqlDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ISqlDialect.cs @@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql; /// . Any code path that builds SQL by concatenating an authored tag field /// without going through is a defect (design §8.1). /// Quoting is the backstop, not the only defence. Design §8.1's catalog gate is -/// implemented: resolves every authored table/column against the live +/// implemented: SqlCatalogGate resolves every authored table/column against the live /// catalog at Initialize and replaces it with the catalog's own spelling, so an identifier /// reaching on the poll path is a string this driver read back out of /// / / — not @@ -96,7 +96,7 @@ public interface ISqlDialect /// principal — T-SQL's SELECT SCHEMA_NAME(). Takes no parameters. /// /// - /// Needed by : a tag authored as TagValues rather than + /// Needed by SqlCatalogGate: a tag authored as TagValues rather than /// dbo.TagValues has to be looked up in some schema, and guessing dbo would be a /// silent lie on any estate that maps its service accounts to their own default schema. Asking the /// server is the only answer that matches how the poll query itself will resolve the name. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs similarity index 98% rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs index ca38e8bb..1f9b4c5b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/SqlServerDialect.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/SqlServerDialect.cs @@ -77,7 +77,7 @@ public sealed class SqlServerDialect : ISqlDialect /// The rejection messages deliberately do not echo the offending value — it is untrusted /// input and this exception's text reaches the driver log. /// This is a backstop, not the only defence. Design §8.1's catalog gate - /// () resolves an authored table/column against the live catalog at + /// (SqlCatalogGate) resolves an authored table/column against the live catalog at /// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here /// is a string read back out of the catalog. The rules below still run — the gate uses them as its own /// charset filter, and they must hold for any future caller that has no catalog to check against. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj new file mode 100644 index 00000000..dd9f264b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj @@ -0,0 +1,30 @@ + + + net10.0 + enable + enable + latest + true + true + $(NoWarn);CS1591 + + ZB.MOM.WW.OtOpcUa.Driver.Sql + ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect + + + + + + + + + + + + + diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj index 66ddb720..559f1ab6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj @@ -13,6 +13,9 @@ + + diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor index b616571a..94a76250 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Pickers/SqlAddressPickerBody.razor @@ -269,7 +269,7 @@ else // never written to the composed TagConfig blob nor back to the driver config — this is the // sole place it is read, and it dies with the local variable. var json = BuildBrowseConfig(); - var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default); + var result = await BrowserService.OpenAsync(DriverTypeNames.Sql, json, default); if (result.Ok) { _token = result.Token; } else { _openError = result.Message; } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs index 978e4d9c..bd58ccf3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs @@ -22,10 +22,7 @@ public static class TagConfigEditorMap [DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor), [DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor), [DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor), - // Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The - // note that used to sit here — "that shared constant is deliberately absent until Task 11 wires - // the factory" — went stale when the constant landed; the key is correct either way. - [SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), + [DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor), [DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor), [DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor), }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs index 863f1552..fb713cf6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs @@ -24,8 +24,7 @@ public static class TagConfigValidator [DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(), - // Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap. - [SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(), + [DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(), [DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(), }; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj index a81f6b25..4b1727c9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj @@ -41,8 +41,10 @@ + .Contracts + .Dialect + Microsoft.Data.SqlClient transitively — SqlClient is + reviewed/accepted (AdminUI already carries it for ConfigDb). It no longer pulls the + runtime Driver.Sql, nor Core through it: see Gitea #526 and the Browser project's own + csproj comment. --> diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs new file mode 100644 index 00000000..c8e70589 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DriverBrowserLayeringTests.cs @@ -0,0 +1,152 @@ +using System.Text.RegularExpressions; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing; + +/// +/// Pins the layering rule for every bespoke driver browser in the tree: a *.Browser +/// project must not reach its runtime *.Driver project, by any path. +/// +/// +/// +/// Why this exists rather than a per-driver test. Gitea #512 removed this violation from +/// the MQTT browser and pinned it with MqttBrowserLayeringTests. The identical violation was +/// sitting in Driver.Sql.Browser the whole time and no guard saw it, because the guard was +/// written for the driver that happened to have an issue filed (#526). This test enumerates the +/// browsers off disk, so a browser added later is covered on the day it is added. +/// +/// +/// Why it reads csproj files rather than loaded assemblies. The cost being prevented is a +/// build/deploy-graph cost: the AdminUI references these browsers, so a runtime driver reachable +/// from one lands in the AdminUI's output whether or not a single type is used. The compiler emits +/// no assembly reference for an unused ProjectReference, so an assembly-reference test — +/// which is what MqttBrowserLayeringTests does, and says so — cannot see that case. The two +/// are complementary: this one catches the reference, that one catches the usage. +/// +/// +/// The walk is transitive. Asserting only on direct references would pass a browser that +/// reached its runtime driver through a shared leaf, which is the same defect one hop further out +/// — the hazard MqttBrowserLayeringTests guards for .Transport by hand. +/// +/// +[Trait("Category", "Unit")] +public sealed class DriverBrowserLayeringTests +{ + /// + /// Browsers known to exist when this guard was written. Asserted as a MINIMUM, not an exact set — + /// a new browser must be covered automatically, so this list only defends against the discovery + /// itself silently finding nothing (a wrong repo root, a moved directory), which would make every + /// assertion below vacuously true. + /// + private static readonly string[] KnownBrowsers = + [ + "ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser", + "ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser", + "ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser", + "ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser", + ]; + + [Fact] + public void Every_bespoke_browser_is_discovered() + { + // The positive control for the whole file. Every other test here is an ABSENCE assertion over a + // discovered set, so an empty set passes them all. + var discovered = DiscoverBrowsers().Select(p => Path.GetFileNameWithoutExtension(p)!).ToArray(); + + foreach (var known in KnownBrowsers) + discovered.ShouldContain(known); + } + + [Fact] + public void Every_browser_declares_at_least_one_project_reference() + // The second half of the control: proves the csproj parse actually returns something, so a + // regex that silently stopped matching cannot turn the rule below into a no-op. + => DiscoverBrowsers().ShouldAllBe(p => DirectReferences(p).Count > 0); + + [Fact] + public void No_browser_reaches_its_runtime_driver_project() + { + var offenders = new List(); + + foreach (var browser in DiscoverBrowsers()) + { + var browserName = Path.GetFileNameWithoutExtension(browser)!; + + // "ZB…Driver.Sql.Browser" -> "ZB…Driver.Sql": the sibling runtime project. + var runtime = browserName[..^".Browser".Length]; + + var reached = TransitiveReferences(browser); + if (reached.Contains(runtime)) + offenders.Add($"{browserName} -> {runtime}"); + } + + offenders.ShouldBeEmpty( + "a driver browser reaches its runtime driver project. The AdminUI references these browsers, " + + "so the whole driver runtime lands in its build/deploy graph — the cost is invisible, which " + + "is exactly why it needs a guard. If shared code is needed, put transport-free protocol or " + + "contract types in .Contracts, and anything that must name a provider/vendor type in a leaf " + + "project of its own (see Driver.Mqtt.Transport and Driver.Sql.Dialect). Do NOT resolve this " + + "by copying the shared code into the browser: for anything touching credentials, TLS or " + + "identifier quoting, a second copy is a silent security divergence rather than a build break."); + } + + // ---- csproj graph ------------------------------------------------------------------------------ + + private static readonly Regex ProjectReferencePattern = new( + " DiscoverBrowsers() => + [.. Directory + .EnumerateDirectories(Path.Combine(RepoRoot.Value, "src", "Drivers"), "*.Browser") + .Select(d => Path.Combine(d, Path.GetFileName(d) + ".csproj")) + .Where(File.Exists) + .OrderBy(p => p, StringComparer.Ordinal)]; + + /// Absolute paths of the csproj files one project references directly. + private static IReadOnlyList DirectReferences(string csprojPath) + { + var dir = Path.GetDirectoryName(csprojPath)!; + + return + [ + .. ProjectReferencePattern + .Matches(File.ReadAllText(csprojPath)) + .Select(m => Path.GetFullPath( + Path.Combine(dir, m.Groups[1].Value.Replace('\\', Path.DirectorySeparatorChar)))) + ]; + } + + /// Assembly-ish names of every project reachable from . + private static HashSet TransitiveReferences(string csprojPath) + { + var reached = new HashSet(StringComparer.OrdinalIgnoreCase); + var pending = new Stack(DirectReferences(csprojPath)); + + while (pending.Count > 0) + { + var next = pending.Pop(); + if (!reached.Add(Path.GetFileNameWithoutExtension(next)!) || !File.Exists(next)) continue; + + foreach (var onward in DirectReferences(next)) pending.Push(onward); + } + + return reached; + } + + /// + /// Walks up from the test binary until the solution file appears. The tests run out of + /// bin/Debug/net10.0, so no fixed relative depth is reliable across host layouts. + /// + private static readonly Lazy RepoRoot = new(() => + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) + dir = dir.Parent; + + dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)"); + return dir.FullName; + }); +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs index 79c36015..cc3f1a2f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttBrowserLayeringTests.cs @@ -22,6 +22,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests; /// ProjectReference re-added but never used would not trip it (the compiler emits no /// reference), so the csproj comment remains the primary record of the decision. /// +/// +/// That gap is now covered by DriverBrowserLayeringTests (Commons.Tests), which +/// scans the csproj graph of every *.Browser project in the tree — added for Gitea #526, +/// after the identical violation was found sitting in Driver.Sql.Browser with no guard +/// watching it, because this file was written for the one driver that had an issue filed. What +/// stays here is the MQTT-specific half: the .Transport leaf shape and the +/// MQTTnet-free-ness of .Contracts. +/// /// [Trait("Category", "Unit")] public sealed class MqttBrowserLayeringTests diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj index 4ccd43cf..88277f85 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ZB.MOM.WW.OtOpcUa.AdminUI.Tests.csproj @@ -25,6 +25,13 @@ + +