b815446940
Both `IDriver` members had no production call site. Their only consumer was `MemoryTracking`, deleted with the driver-tier recycle machinery in #522, and `GetMemoryFootprint`'s doc-comment claimed "polled every 30s by Core" — which had not been true since v3, even before #522 removed the poller. Owner decision on #525 was (a) delete rather than (b) build the process-wide watchdog that would consume them. The driver-side implementations are in git history and were mostly reusable; what was always missing is the trigger, and a member nobody calls is how the misleading doc-comment survived this long. Removed from `IDriver`, all 12 driver implementations, and every test stub — plus the support code that existed only to feed them, which is the part a member-only deletion would have left behind as dead weight: - MTConnect's three per-entry byte-weight constants, its cached `ProbeModelDataItemCount`, and `CountDataItems` — a full device-tree walk run on every cache fill to populate a number with no remaining reader. - OpcUaClient's `_discoveredNodeCount` field and the discovery-pass write to it. - MQTT's `ApproxBytesPerAuthoredTag`. Two behavioural notes, neither a change in what the software does: - `AbCipTemplateCache` now has no production reset path. Its only caller was AB CIP's flush, which itself had no caller — so nothing changes except how visible the fact is. Recorded on the type, along with the correction that its comment's claim about a device `ReinitializeAsync` also flushing it was never true. A UDT redefined on a live controller keeps its stale shape until the driver instance is rebuilt. - MTConnect's `/probe` cache is still dropped, by `InvalidateProbeModel()` on an Agent restart. That is now `internal` rather than `private` so the tests which used the flush purely to empty the cache drive the real path instead — they now cover a case that actually occurs in production. Several unrelated doc comments cited `FlushOptionalCachesAsync` as the canonical CAS example and are repointed to it. Tests: the ones that existed only to assert on the removed members are deleted (3 OpcUaClient, 3 Galaxy, 2 MQTT, 1 MTConnect, 1 AbCip). Worth noting one was vacuous — `FlushOptionalCachesAsync_resets_footprint_counter` asserted a footprint of 0 before the flush and 0 after with no discovery in between, so it would have passed against a flush that did nothing at all. Full suite green except 7 failures verified identical on master before and after (4 AbLegacy integration with the compose profile down, 3 Host.IntegrationTests), checked by name rather than assumed. Docs corrected where they described this as live: `DriverLifecycle.md` (which carried the #525 placeholder), `drivers/AbCip.md`, `drivers/AbLegacy.md`, and `v2/driver-stability.md` — whose "polls this every 30 s" + per-driver `DriverConfig` memory budget were never built as written and are now banner-ed as design intent rather than description. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
300 lines
14 KiB
C#
300 lines
14 KiB
C#
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
|
||
|
||
/// <summary>
|
||
/// Smoke tests for the PR 4.0 driver skeleton. The IDriver shape, factory parsing,
|
||
/// and lifecycle methods land in PR 4.0; capability bodies (browse / read / write /
|
||
/// subscribe / health forwarder / probe watcher) are tested in PRs 4.1–4.7 each
|
||
/// against their own seam.
|
||
/// </summary>
|
||
public sealed class GalaxyDriverFactoryTests
|
||
{
|
||
private const string MinimalConfig = """
|
||
{
|
||
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" },
|
||
"MxAccess": { "ClientName": "OtOpcUa-A" }
|
||
}
|
||
""";
|
||
|
||
/// <summary>Verifies that minimal config is parsed and defaults are applied.</summary>
|
||
[Fact]
|
||
public void CreateInstance_ParsesMinimalConfig_AndAppliesDefaults()
|
||
{
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-instance-a", MinimalConfig);
|
||
|
||
driver.DriverInstanceId.ShouldBe("galaxy-instance-a");
|
||
driver.DriverType.ShouldBe(GalaxyDriverFactoryExtensions.DriverTypeName);
|
||
driver.Options.Gateway.Endpoint.ShouldBe("https://mxgw.test:5001");
|
||
driver.Options.Gateway.ApiKeySecretRef.ShouldBe("galaxy:apiKey");
|
||
driver.Options.Gateway.UseTls.ShouldBeTrue();
|
||
driver.Options.Gateway.ConnectTimeoutSeconds.ShouldBe(10);
|
||
driver.Options.MxAccess.ClientName.ShouldBe("OtOpcUa-A");
|
||
driver.Options.MxAccess.PublishingIntervalMs.ShouldBe(1000);
|
||
driver.Options.Repository.DiscoverPageSize.ShouldBe(5000);
|
||
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue();
|
||
}
|
||
|
||
/// <summary>Verifies that defaults are overridden from full config.</summary>
|
||
[Fact]
|
||
public void CreateInstance_OverridesDefaults_FromFullConfig()
|
||
{
|
||
const string fullConfig = """
|
||
{
|
||
"Gateway": {
|
||
"Endpoint": "https://mxgw.prod:5001",
|
||
"ApiKeySecretRef": "secret:abc",
|
||
"UseTls": false,
|
||
"CaCertificatePath": "C:/certs/ca.crt",
|
||
"ConnectTimeoutSeconds": 5,
|
||
"DefaultCallTimeoutSeconds": 3,
|
||
"StreamTimeoutSeconds": 60
|
||
},
|
||
"MxAccess": {
|
||
"ClientName": "OtOpcUa-Prod",
|
||
"PublishingIntervalMs": 250,
|
||
"WriteUserId": 17
|
||
},
|
||
"Repository": { "DiscoverPageSize": 1000, "WatchDeployEvents": false },
|
||
"Reconnect": { "InitialBackoffMs": 100, "MaxBackoffMs": 5000, "ReplayOnSessionLost": false }
|
||
}
|
||
""";
|
||
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-prod", fullConfig);
|
||
|
||
driver.Options.Gateway.UseTls.ShouldBeFalse();
|
||
driver.Options.Gateway.CaCertificatePath.ShouldBe("C:/certs/ca.crt");
|
||
driver.Options.Gateway.ConnectTimeoutSeconds.ShouldBe(5);
|
||
driver.Options.MxAccess.PublishingIntervalMs.ShouldBe(250);
|
||
driver.Options.MxAccess.WriteUserId.ShouldBe(17);
|
||
driver.Options.Repository.DiscoverPageSize.ShouldBe(1000);
|
||
driver.Options.Repository.WatchDeployEvents.ShouldBeFalse();
|
||
driver.Options.Reconnect.InitialBackoffMs.ShouldBe(100);
|
||
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeFalse();
|
||
}
|
||
|
||
/// <summary>Verifies that the factory binds the v3 RawTags array from the driver config JSON.</summary>
|
||
[Fact]
|
||
public void CreateInstance_BindsRawTags_FromConfig()
|
||
{
|
||
const string configWithRawTags = """
|
||
{
|
||
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "galaxy:apiKey" },
|
||
"MxAccess": { "ClientName": "OtOpcUa-A" },
|
||
"RawTags": [
|
||
{ "RawPath": "area/line/tank1/level", "TagConfig": "{\"attributeRef\":\"Tank1.Level\"}", "WriteIdempotent": true },
|
||
{ "RawPath": "area/line/tank1/sp", "TagConfig": "{\"attributeRef\":\"Tank1.SP\"}", "WriteIdempotent": false }
|
||
]
|
||
}
|
||
""";
|
||
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-rawtags", configWithRawTags);
|
||
|
||
driver.Options.RawTags.Count.ShouldBe(2);
|
||
driver.Options.RawTags[0].RawPath.ShouldBe("area/line/tank1/level");
|
||
driver.Options.RawTags[0].TagConfig.ShouldContain("Tank1.Level");
|
||
driver.Options.RawTags[0].WriteIdempotent.ShouldBeTrue();
|
||
driver.Options.RawTags[1].WriteIdempotent.ShouldBeFalse();
|
||
}
|
||
|
||
/// <summary>Verifies that config without a RawTags array binds to an empty list (identity path).</summary>
|
||
[Fact]
|
||
public void CreateInstance_NoRawTags_DefaultsToEmpty()
|
||
{
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-none", MinimalConfig);
|
||
driver.Options.RawTags.ShouldBeEmpty();
|
||
}
|
||
|
||
/// <summary>Verifies that missing endpoint throws an exception.</summary>
|
||
[Fact]
|
||
public void CreateInstance_MissingEndpoint_Throws()
|
||
{
|
||
const string bad = """{"Gateway":{"ApiKeySecretRef":"x"},"MxAccess":{"ClientName":"y"}}""";
|
||
Should.Throw<InvalidOperationException>(
|
||
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("Gateway.Endpoint");
|
||
}
|
||
|
||
/// <summary>Verifies that missing API key throws an exception.</summary>
|
||
[Fact]
|
||
public void CreateInstance_MissingApiKey_Throws()
|
||
{
|
||
const string bad = """{"Gateway":{"Endpoint":"x"},"MxAccess":{"ClientName":"y"}}""";
|
||
Should.Throw<InvalidOperationException>(
|
||
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("ApiKeySecretRef");
|
||
}
|
||
|
||
/// <summary>Verifies that missing client name throws an exception.</summary>
|
||
[Fact]
|
||
public void CreateInstance_MissingClientName_Throws()
|
||
{
|
||
const string bad = """{"Gateway":{"Endpoint":"x","ApiKeySecretRef":"y"}}""";
|
||
Should.Throw<InvalidOperationException>(
|
||
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("MxAccess.ClientName");
|
||
}
|
||
|
||
/// <summary>Verifies that the factory is registered in the driver factory registry.</summary>
|
||
[Fact]
|
||
public void Register_AddsFactoryToRegistry()
|
||
{
|
||
var registry = new DriverFactoryRegistry();
|
||
GalaxyDriverFactoryExtensions.Register(registry, NullSecretResolver.Instance);
|
||
|
||
registry.RegisteredTypes.ShouldContain(GalaxyDriverFactoryExtensions.DriverTypeName);
|
||
|
||
var factory = registry.TryGet(GalaxyDriverFactoryExtensions.DriverTypeName);
|
||
factory.ShouldNotBeNull();
|
||
|
||
var driver = factory!.Invoke("galaxy-x", MinimalConfig);
|
||
driver.ShouldNotBeNull();
|
||
driver.DriverInstanceId.ShouldBe("galaxy-x");
|
||
driver.DriverType.ShouldBe(GalaxyDriverFactoryExtensions.DriverTypeName);
|
||
}
|
||
|
||
/// <summary>Verifies that driver lifecycle toggles health state on initialize and shutdown.</summary>
|
||
[Fact]
|
||
public async Task DriverLifecycle_InitializeShutdown_ToggleHealth()
|
||
{
|
||
// Inject a no-op subscriber seam so InitializeAsync skips BuildProductionRuntimeAsync
|
||
// (no gw connect attempt). The factory tests only care about the lifecycle health
|
||
// toggle; real-runtime wire-up is exercised in PR 4.W's BuildProductionRuntime tests.
|
||
using var driver = new GalaxyDriver(
|
||
"galaxy-x", BuildOptions(), hierarchySource: null, dataReader: null,
|
||
dataWriter: null, subscriber: new NoopSubscriber());
|
||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||
|
||
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
|
||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||
driver.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
|
||
|
||
await driver.ShutdownAsync(CancellationToken.None);
|
||
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||
}
|
||
|
||
/// <summary>Verifies that reinitializing with equivalent config refreshes health.</summary>
|
||
[Fact]
|
||
public async Task ReinitializeAsync_RefreshesHealth_WhenConfigIsEquivalent()
|
||
{
|
||
// Driver.Galaxy-013: ReinitializeAsync now compares the incoming JSON to the
|
||
// live options. An equivalent config is accepted and refreshes health; a
|
||
// non-equivalent reapply throws NotSupportedException (covered in
|
||
// GalaxyDriverInfrastructureTests.ReinitializeAsync_RejectsNonEquivalentConfigChange).
|
||
// Build a config JSON whose parsed shape equals BuildOptions() so the
|
||
// equivalence check passes.
|
||
const string equivalentConfig = """
|
||
{
|
||
"Gateway": { "Endpoint": "https://mxgw.test:5001", "ApiKeySecretRef": "key" },
|
||
"MxAccess": { "ClientName": "OtOpcUa-A" }
|
||
}
|
||
""";
|
||
using var driver = new GalaxyDriver(
|
||
"galaxy-x", BuildOptions(), hierarchySource: null, dataReader: null,
|
||
dataWriter: null, subscriber: new NoopSubscriber());
|
||
await driver.InitializeAsync(equivalentConfig, CancellationToken.None);
|
||
var firstStamp = driver.GetHealth().LastSuccessfulRead!.Value;
|
||
|
||
// Force a measurable clock delta so the comparison is stable on fast machines.
|
||
await Task.Delay(20);
|
||
await driver.ReinitializeAsync(equivalentConfig, CancellationToken.None);
|
||
|
||
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||
driver.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(firstStamp);
|
||
}
|
||
|
||
private static GalaxyDriverOptions BuildOptions() => new(
|
||
new GalaxyGatewayOptions("https://mxgw.test:5001", "key"),
|
||
new GalaxyMxAccessOptions("OtOpcUa-A"),
|
||
new GalaxyRepositoryOptions(),
|
||
new GalaxyReconnectOptions());
|
||
|
||
/// <summary>
|
||
/// Minimum-surface <see cref="IGalaxySubscriber"/> seam — enough to satisfy
|
||
/// <see cref="GalaxyDriver.InitializeAsync"/>'s "skip production-runtime build" branch
|
||
/// without driving any actual subscribe/event-pump traffic.
|
||
/// </summary>
|
||
private sealed class NoopSubscriber : IGalaxySubscriber
|
||
{
|
||
/// <inheritdoc />
|
||
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
||
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
||
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
|
||
|
||
/// <inheritdoc />
|
||
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
||
=> Task.CompletedTask;
|
||
|
||
/// <inheritdoc />
|
||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||
{
|
||
await Task.CompletedTask;
|
||
yield break;
|
||
}
|
||
}
|
||
|
||
/// <summary>Verifies that dispose is idempotent and shutdown after dispose is harmless.</summary>
|
||
[Fact]
|
||
public void Dispose_IsIdempotent_AndShutdownAfterDisposeIsHarmless()
|
||
{
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig);
|
||
driver.Dispose();
|
||
Should.NotThrow(() => driver.Dispose());
|
||
}
|
||
|
||
/// <summary>Verifies that initializing after dispose throws an exception.</summary>
|
||
[Fact]
|
||
public async Task InitializeAfterDispose_Throws()
|
||
{
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("galaxy-x", MinimalConfig);
|
||
driver.Dispose();
|
||
await Should.ThrowAsync<ObjectDisposedException>(() =>
|
||
driver.InitializeAsync(MinimalConfig, CancellationToken.None));
|
||
}
|
||
|
||
/// <summary>Verifies that the driver implements all Phase 4 capabilities.</summary>
|
||
[Fact]
|
||
public void DriverImplementsAllPhase4Capabilities()
|
||
{
|
||
// PR 4.W contract pin — every capability the in-process Galaxy driver must
|
||
// expose for parity with GalaxyProxyDriver. If any of these regress, the
|
||
// Galaxy:Backend flag flip in PR 7.1 will silently lose surface.
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("g", MinimalConfig);
|
||
driver.ShouldBeAssignableTo<ITagDiscovery>();
|
||
driver.ShouldBeAssignableTo<IReadable>();
|
||
driver.ShouldBeAssignableTo<IWritable>();
|
||
driver.ShouldBeAssignableTo<ISubscribable>();
|
||
driver.ShouldBeAssignableTo<IRediscoverable>();
|
||
driver.ShouldBeAssignableTo<IHostConnectivityProbe>();
|
||
}
|
||
|
||
/// <summary>Verifies that GetHostStatuses returns an empty snapshot after initialization with seam.</summary>
|
||
[Fact]
|
||
public async Task GetHostStatuses_AfterInitWithSeam_ReturnsEmptySnapshot()
|
||
{
|
||
// PR 4.W wire-up assertion: when InitializeAsync skips the production-runtime
|
||
// build (seam injected), no transport-forwarder or probe-watcher pushes a
|
||
// status, so the aggregator snapshot is empty. The forwarder + watcher have
|
||
// their own unit tests in PR 4.7.
|
||
using var driver = new GalaxyDriver(
|
||
"g", BuildOptions(), hierarchySource: null, dataReader: null,
|
||
dataWriter: null, subscriber: new NoopSubscriber());
|
||
await driver.InitializeAsync(MinimalConfig, CancellationToken.None);
|
||
driver.GetHostStatuses().ShouldBeEmpty();
|
||
}
|
||
|
||
/// <summary>Verifies that the driver type is GalaxyMxGateway, not the legacy Galaxy type.</summary>
|
||
[Fact]
|
||
public void DriverType_IsGalaxyMxGateway_NotLegacyGalaxy()
|
||
{
|
||
// Must match GalaxyProxyDriver's DriverType ("Galaxy") side-by-side without
|
||
// collision so DriverInstanceBootstrapper can resolve both factories.
|
||
var driver = GalaxyDriverFactoryExtensions.CreateInstance("g", MinimalConfig);
|
||
driver.DriverType.ShouldBe("GalaxyMxGateway");
|
||
}
|
||
}
|