d32d89c340
Five drivers ignored the config handed to them on reinitialize, serving the
options their constructor captured — and the deployment sealed green anyway.
Fixed on both halves, as chosen.
Seam (load-bearing): DriverSpawnPlanner routes a changed DriverConfig to
ToStop + ToSpawn instead of an in-place delta, making the factory the single
parse authority. This REVERSES the deliberate decision documented at
DriverSpawnPlan.cs:49-50 ("a pure DriverConfig change stays an in-place delta
— no reconnect"). That reasoning was correct about the resilience pipeline and
wrong about the driver. The accepted price is a reconnect per config edit.
Per-driver, and this split was not anticipated:
- Re-parse in place — Modbus, AbLegacy, OpcUaClient. ParseOptions extracted
from each factory, called from InitializeAsync behind a HasConfigBody guard
so "{}" still keeps the constructor options.
- Respawn-only, deliberately NOT re-parsed — Sql and FOCAS. Each builds more
than options from config (Sql's dialect + resolved connection string,
FOCAS's client-factory backend, both injected at construction), so adopting
new options alone would run a NEW tag set against an OLD connection. Half a
re-parse is worse than none. SqlDriver's doc-comment asserted the opposite
premise — "config parsing belongs to the factory, which builds a fresh
instance" — which was false when written and is true only now.
Two green seals removed from ApplyChildDelta: it overwrote the cached Spec
synchronously BEFORE the child dequeued the message, so the host believed the
new config was live and the next reconcile computed no delta, sealing the
drift permanently; and it Tell'd with no Receive<ApplyResult> registered, so a
failed reinit — including Galaxy's deliberate NotSupportedException —
dead-lettered.
Exposed (not created) by the change: a factory throw is a CONFIG error, and
SpawnChild catches it and silently substitutes a stub. Only a brand-new driver
could reach that before; an ordinary config edit can now. Raised Warning ->
Error with an actionable message. It still does not fail the deployment —
doing so would let one malformed driver block a fleet deploy, so that is a
follow-up rather than a drive-by.
Tests: every pre-existing reinit test in these suites passes "{}", the exact
input a guarded re-parser treats as "keep constructor options" — blind to this
defect by construction. New tests pass CHANGED json; the Modbus one was
verified falsifiable by deleting the re-parse (goes red). Two tests asserted
the old behaviour and were rewritten, including the positive control in
DriverHostActorUnreadableArtifactTests, whose observable moved from
UnsubscribeAsync to ShutdownAsync now that teardown happens by stopping the
child rather than emptying its desired set.
Remaining full-suite failures are pre-existing and environmental, verified
identical on the pre-change tree: Host.IntegrationTests (3) and
Driver.AbLegacy.IntegrationTests (4, docker fixture-gated).
339 lines
18 KiB
C#
339 lines
18 KiB
C#
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
|
|
|
|
/// <summary>
|
|
/// Issue #485 (driver-side half) — an artifact the host could not obtain must not be mistaken for a
|
|
/// configuration that contains nothing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <c>ReconcileDrivers</c> and <c>PushDesiredSubscriptions</c> both read the artifact as
|
|
/// <c>…FirstOrDefault() ?? Array.Empty<byte>()</c>. The <b>throw</b> path in each already
|
|
/// returns early, but a row that is absent (or carries a zero-length blob) yields empty bytes that
|
|
/// flow onwards as a real answer: zero driver specs, so <c>DriverSpawnPlanner</c> plans every
|
|
/// running child for <c>StopChild</c>, and then an empty desired-subscription set drops each
|
|
/// surviving driver's live handle. A node loses its entire field I/O and still ACKs Applied.
|
|
/// </para>
|
|
/// <para>
|
|
/// The same reasoning as the address-space half applies: nothing legitimate produces a zero-length
|
|
/// blob — an operator who really deletes every driver still deploys a JSON document with empty
|
|
/// arrays — so "no bytes" can only mean the read did not answer. Seeding a row whose
|
|
/// <c>ArtifactBlob</c> is empty reproduces exactly the bytes the missing-row case delivers, without
|
|
/// needing to race a delete against the two reads.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class DriverHostActorUnreadableArtifactTests : RuntimeActorTestBase
|
|
{
|
|
private static readonly NodeId TestNode = NodeId.Parse("driver-test");
|
|
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
|
|
private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64));
|
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(5);
|
|
|
|
/// <summary>How long to let post-ACK subscription traffic settle before asserting it did NOT happen.</summary>
|
|
private static readonly TimeSpan SettleWindow = TimeSpan.FromSeconds(2);
|
|
|
|
private const string SpeedRef = "Plant/Modbus/dev1/speed";
|
|
|
|
/// <summary>A dispatch whose artifact reads back as no bytes must leave the running drivers — and their
|
|
/// live subscriptions — exactly as they were.</summary>
|
|
[Fact]
|
|
public void Dispatch_whose_artifact_reads_back_empty_keeps_the_drivers_and_their_subscriptions()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new SubscribingDriverFactory("Modbus");
|
|
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
|
|
|
|
// Baseline: the child is up and subscribed to its one raw tag.
|
|
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
|
|
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1");
|
|
|
|
// The next dispatch's artifact comes back as no bytes at all.
|
|
actor.Tell(new DispatchDeployment(SeedUnreadableDeployment(db, RevB), RevB, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(Timeout);
|
|
|
|
// The ACK precedes the SubscribeBulk pass, and the child's unsubscribe is a further async self-tell,
|
|
// so settle before asserting an ABSENCE. SettleWindow is calibrated by
|
|
// <see cref="Dropping_a_drivers_last_tag_does_clear_its_subscription"/>, which observes the very same
|
|
// unsubscribe ARRIVING well inside it — without that control this assertion would pass on a race.
|
|
AskDiagnostics(actor); // ordering barrier through the host's mailbox
|
|
Thread.Sleep(SettleWindow);
|
|
|
|
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // not stopped
|
|
factory.UnsubscribeCount.ShouldBe(0); // handle not dropped
|
|
factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Positive control for the subscription half: a READABLE artifact that drops the driver's last tag
|
|
/// genuinely does tear the live subscription down, and does so well within
|
|
/// <see cref="SettleWindow"/> — without this, the absence asserted above would pass on a race.
|
|
/// <para><b>The observable changed with #516.</b> Dropping a tag changes the driver's
|
|
/// <c>DriverConfig</c>, which used to be an in-place delta: the child received an empty desired set
|
|
/// and called <c>UnsubscribeAsync</c>. A config change is now a stop + respawn, so the old child is
|
|
/// STOPPED (taking its subscription with it via <c>ShutdownAsync</c>) and a fresh one spawns with
|
|
/// nothing to subscribe to. The teardown is therefore observed as a shutdown, not an unsubscribe —
|
|
/// the same event, reached by a different route.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void Dropping_a_drivers_last_tag_does_clear_its_subscription()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new SubscribingDriverFactory("Modbus");
|
|
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
|
|
|
|
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
|
|
factory.ShutdownCount.ShouldBe(0);
|
|
|
|
actor.Tell(new DispatchDeployment(SeedTaglessDriverDeployment(db, RevB), RevB, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(Timeout);
|
|
|
|
AwaitAssert(() => factory.ShutdownCount.ShouldBeGreaterThanOrEqualTo(1), duration: SettleWindow);
|
|
AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-1"); // respawned under the same id
|
|
}
|
|
|
|
/// <summary>Positive control: the guard keys on "the read gave us nothing", not on "the new config has
|
|
/// fewer drivers". A READABLE artifact that genuinely drops the driver still stops it — otherwise the
|
|
/// test above would pass just as happily against a host that had stopped reconciling altogether.</summary>
|
|
[Fact]
|
|
public void Dispatch_whose_readable_artifact_genuinely_drops_the_driver_still_stops_it()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new SubscribingDriverFactory("Modbus");
|
|
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
|
|
|
|
AwaitAssert(() => factory.LastSubscribedRefs.ShouldNotBeNull()!.ShouldContain(SpeedRef), duration: Timeout);
|
|
|
|
// A real artifact that simply carries no drivers — an operator deleting the last driver.
|
|
actor.Tell(new DispatchDeployment(SeedDriverlessDeployment(db, RevB), RevB, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(Timeout);
|
|
|
|
AwaitAssert(
|
|
() => AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldNotContain("drv-1"),
|
|
duration: Timeout);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Issue #486 — an apply that read no artifact applied nothing, so it must NOT be reported as
|
|
/// Applied. Reporting success also advanced <c>_currentRevision</c>, and because
|
|
/// <c>HandleDispatchFromSteady</c> short-circuits on a revision match, the node could never be
|
|
/// healed by re-dispatching that revision.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Dispatch_whose_artifact_reads_back_empty_acks_Failed_and_leaves_the_revision_alone()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new SubscribingDriverFactory("Modbus");
|
|
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
|
|
AskDiagnostics(actor).CurrentRevision.ShouldBe(RevA);
|
|
|
|
actor.Tell(new DispatchDeployment(SeedUnreadableDeployment(db, RevB), RevB, CorrelationId.NewId()));
|
|
var ack = coordinator.ExpectMsg<ApplyAck>(Timeout);
|
|
|
|
ack.Outcome.ShouldBe(ApplyAckOutcome.Failed);
|
|
ack.FailureReason.ShouldNotBeNullOrWhiteSpace();
|
|
|
|
var snapshot = AskDiagnostics(actor);
|
|
snapshot.CurrentRevision.ShouldBe(RevA); // never applied ⇒ never claimed
|
|
snapshot.Drivers.Select(d => d.Name).ShouldContain("drv-1"); // …and #485 still holds
|
|
}
|
|
|
|
/// <summary>Issue #486 — the point of failing the apply: the SAME revision can be dispatched again and
|
|
/// now actually applies, instead of being waved through by the "already at this rev" short-circuit. The
|
|
/// recovered artifact adds a second driver, so a short-circuited ACK (which has no side effects) cannot
|
|
/// satisfy this test — drv-2 only appears if the artifact was genuinely re-read and reconciled.</summary>
|
|
[Fact]
|
|
public void A_failed_apply_is_retried_not_short_circuited_once_the_artifact_is_readable_again()
|
|
{
|
|
var db = NewInMemoryDbFactory();
|
|
var factory = new SubscribingDriverFactory("Modbus");
|
|
var (actor, coordinator) = SpawnHostAndApply(db, SeedV3Deployment(db, RevA), factory);
|
|
|
|
var deploymentId = SeedUnreadableDeployment(db, RevB);
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevB, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Failed);
|
|
|
|
// The ConfigDb recovers: the row now carries a real artifact, under the SAME revision.
|
|
SetArtifact(db, deploymentId, TwoDriverArtifact());
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevB, CorrelationId.NewId()));
|
|
|
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
AwaitAssert(
|
|
() => AskDiagnostics(actor).Drivers.Select(d => d.Name).ShouldContain("drv-2"),
|
|
duration: Timeout);
|
|
AskDiagnostics(actor).CurrentRevision.ShouldBe(RevB);
|
|
}
|
|
|
|
/// <summary>Spawns the host with the subscribing factory, dispatches <paramref name="deploymentId"/> and
|
|
/// waits for the Applied ACK so the child + the initial SubscribeBulk pass are in place.</summary>
|
|
private (IActorRef Actor, Akka.TestKit.TestProbe Coordinator) SpawnHostAndApply(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, IDriverFactory factory)
|
|
{
|
|
var coordinator = CreateTestProbe();
|
|
var actor = Sys.ActorOf(DriverHostActor.Props(
|
|
db, TestNode, coordinator.Ref,
|
|
driverFactory: factory,
|
|
localRoles: new HashSet<string> { "driver" }));
|
|
|
|
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
|
|
coordinator.ExpectMsg<ApplyAck>(Timeout).Outcome.ShouldBe(ApplyAckOutcome.Applied);
|
|
return (actor, coordinator);
|
|
}
|
|
|
|
private NodeDiagnosticsSnapshot AskDiagnostics(IActorRef actor)
|
|
{
|
|
var probe = CreateTestProbe();
|
|
actor.Tell(new GetDiagnostics(CorrelationId.NewId()), probe.Ref);
|
|
return probe.ExpectMsg<NodeDiagnosticsSnapshot>(Timeout);
|
|
}
|
|
|
|
/// <summary>Seeds a Sealed v3 deployment: RawFolder "Plant" → DriverInstance "drv-1" (Modbus, ENABLED so a
|
|
/// real child spawns) → Device "dev1" → Tag "speed" (RawPath <c>Plant/Modbus/dev1/speed</c>).</summary>
|
|
private static DeploymentId SeedV3Deployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev) =>
|
|
SeedDeployment(db, rev, JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
|
},
|
|
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
|
TagGroups = Array.Empty<object>(),
|
|
Tags = new[]
|
|
{
|
|
new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
|
|
},
|
|
}));
|
|
|
|
/// <summary>A readable artifact carrying drv-1 AND drv-2, so an apply that genuinely happens is
|
|
/// distinguishable from a short-circuited ACK (which spawns nothing).</summary>
|
|
private static byte[] TwoDriverArtifact() => JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
|
new { DriverInstanceId = "drv-2", RawFolderId = "rf-plant", Name = "Modbus2", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
|
},
|
|
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
|
TagGroups = Array.Empty<object>(),
|
|
Tags = new[]
|
|
{
|
|
new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "speed", DataType = "Double", AccessLevel = 1, TagConfig = "{}" },
|
|
},
|
|
});
|
|
|
|
/// <summary>Replaces a seeded deployment's artifact in place — the ConfigDb becoming readable again
|
|
/// without the revision changing.</summary>
|
|
private static void SetArtifact(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId, byte[] artifact)
|
|
{
|
|
// ArtifactBlob is init-only, so the row is replaced rather than mutated: delete and re-add under
|
|
// the SAME id + revision. Separate SaveChanges calls so the in-memory provider does not see a
|
|
// delete and an insert of the same key in one change set.
|
|
using var ctx = db.CreateDbContext();
|
|
var row = ctx.Deployments.Single(d => d.DeploymentId == deploymentId.Value);
|
|
var rev = row.RevisionHash;
|
|
ctx.Deployments.Remove(row);
|
|
ctx.SaveChanges();
|
|
|
|
ctx.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = deploymentId.Value,
|
|
RevisionHash = rev,
|
|
Status = DeploymentStatus.Sealed,
|
|
CreatedBy = "test",
|
|
SealedAtUtc = DateTime.UtcNow,
|
|
ArtifactBlob = artifact,
|
|
});
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
/// <summary>Seeds a Sealed deployment whose <c>ArtifactBlob</c> is zero-length — byte-for-byte what the
|
|
/// host's <c>?? Array.Empty<byte>()</c> hands downstream when the row cannot be found.</summary>
|
|
private static DeploymentId SeedUnreadableDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev) =>
|
|
SeedDeployment(db, rev, Array.Empty<byte>());
|
|
|
|
/// <summary>Seeds a Sealed deployment carrying a real, readable artifact that keeps drv-1 (so its child
|
|
/// survives the reconcile) but declares no tags for it — the driver's desired set becomes empty.</summary>
|
|
private static DeploymentId SeedTaglessDriverDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev) =>
|
|
SeedDeployment(db, rev, JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
|
|
DriverInstances = new[]
|
|
{
|
|
new { DriverInstanceId = "drv-1", RawFolderId = "rf-plant", Name = "Modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = true },
|
|
},
|
|
Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } },
|
|
TagGroups = Array.Empty<object>(),
|
|
Tags = Array.Empty<object>(),
|
|
}));
|
|
|
|
/// <summary>Seeds a Sealed deployment carrying a real, readable artifact that declares no drivers.</summary>
|
|
private static DeploymentId SeedDriverlessDeployment(IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev) =>
|
|
SeedDeployment(db, rev, JsonSerializer.SerializeToUtf8Bytes(new
|
|
{
|
|
RawFolders = Array.Empty<object>(),
|
|
DriverInstances = Array.Empty<object>(),
|
|
Devices = Array.Empty<object>(),
|
|
TagGroups = Array.Empty<object>(),
|
|
Tags = Array.Empty<object>(),
|
|
}));
|
|
|
|
private static DeploymentId SeedDeployment(
|
|
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, byte[] artifact)
|
|
{
|
|
var id = DeploymentId.NewId();
|
|
using var ctx = db.CreateDbContext();
|
|
ctx.Deployments.Add(new Deployment
|
|
{
|
|
DeploymentId = id.Value,
|
|
RevisionHash = rev.Value,
|
|
Status = DeploymentStatus.Sealed,
|
|
CreatedBy = "test",
|
|
SealedAtUtc = DateTime.UtcNow,
|
|
ArtifactBlob = artifact,
|
|
});
|
|
ctx.SaveChanges();
|
|
return id;
|
|
}
|
|
|
|
/// <summary>Factory producing one shared <see cref="SubscribableStubDriver"/> for the supported type, so a
|
|
/// REAL (non-stubbed) <see cref="DriverInstanceActor"/> child spawns and its subscribe/unsubscribe traffic
|
|
/// is observable.</summary>
|
|
private sealed class SubscribingDriverFactory(string supportedType) : IDriverFactory
|
|
{
|
|
private readonly SubscribableStubDriver _driver = new();
|
|
|
|
/// <summary>The reference set passed to the driver's most recent <c>SubscribeAsync</c> call.</summary>
|
|
public IReadOnlyList<string>? LastSubscribedRefs => _driver.LastSubscribedRefs;
|
|
|
|
/// <summary>Number of <c>UnsubscribeAsync</c> calls — non-zero means a live handle was torn down.</summary>
|
|
public int UnsubscribeCount => Volatile.Read(ref _driver.UnsubscribeCount);
|
|
|
|
/// <summary>Number of <c>ShutdownAsync</c> calls — non-zero means a driver child was stopped.</summary>
|
|
public int ShutdownCount => Volatile.Read(ref _driver.ShutdownCount);
|
|
|
|
/// <inheritdoc />
|
|
public IDriver? TryCreate(string driverType, string driverInstanceId, string driverConfigJson) =>
|
|
string.Equals(driverType, supportedType, StringComparison.Ordinal) ? _driver : null;
|
|
|
|
/// <inheritdoc />
|
|
public IReadOnlyCollection<string> SupportedTypes => new[] { supportedType };
|
|
}
|
|
}
|