821 lines
34 KiB
C#
821 lines
34 KiB
C#
using Shouldly;
|
|
using ZB.MOM.WW.OtOpcUa.Tests;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
|
|
|
/// <summary>
|
|
/// Task 9 — <see cref="MTConnectDriver"/>'s <see cref="IDriver"/> lifecycle over the
|
|
/// <see cref="IMTConnectAgentClient"/> seam: construction, initialize, reinitialize, shutdown,
|
|
/// health, footprint, and cache flush. Every test runs against
|
|
/// <see cref="CannedAgentClient"/>; no socket is opened anywhere in this file.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The load-bearing assertions here are the ones about <b>what must NOT happen</b>: a
|
|
/// constructor that dials, an initialize that faults but still reports Healthy, a re-point that
|
|
/// keeps talking to the old Agent, and a cache flush that wedges browse. Each of those is a
|
|
/// defect the happy-path assertions alone would pass straight over.
|
|
/// </remarks>
|
|
public sealed class MTConnectDriverLifecycleTests(ITestOutputHelper output)
|
|
{
|
|
private const string AgentUri = "http://fixture-agent:5000";
|
|
|
|
/// <summary>Every dataItemId the canned probe declares — the browse-cache footprint basis.</summary>
|
|
private const int FixtureDataItemCount = 7;
|
|
|
|
private static MTConnectDriverOptions Opts(
|
|
string agentUri = AgentUri,
|
|
string? deviceName = null,
|
|
int requestTimeoutMs = 5000,
|
|
int heartbeatMs = 10000,
|
|
int sampleIntervalMs = 1000,
|
|
int sampleCount = 1000) =>
|
|
new()
|
|
{
|
|
AgentUri = agentUri,
|
|
DeviceName = deviceName,
|
|
RequestTimeoutMs = requestTimeoutMs,
|
|
HeartbeatMs = heartbeatMs,
|
|
SampleIntervalMs = sampleIntervalMs,
|
|
SampleCount = sampleCount,
|
|
Tags =
|
|
[
|
|
new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64),
|
|
new MTConnectTagDefinition("dev1_execution", DriverDataType.String),
|
|
],
|
|
};
|
|
|
|
/// <summary>A driver wired to one canned client, plus that client, for the common case.</summary>
|
|
private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
|
|
MTConnectDriverOptions? options = null)
|
|
{
|
|
var client = CannedAgentClient.FromFixtures();
|
|
|
|
return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
|
|
}
|
|
|
|
private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync()
|
|
{
|
|
var (driver, client) = NewDriver();
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
return (driver, client);
|
|
}
|
|
|
|
// ---- connection-free construction ----
|
|
|
|
[Fact]
|
|
public void Constructor_dials_nothing_and_does_not_even_build_a_client()
|
|
{
|
|
var factoryCalls = 0;
|
|
var client = CannedAgentClient.FromFixtures();
|
|
|
|
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
|
{
|
|
factoryCalls++;
|
|
|
|
return client;
|
|
});
|
|
|
|
// The universal discovery browser constructs a throwaway instance purely to ask CanBrowse.
|
|
// If the ctor built (or worse, dialled) a client, every browse probe would open a connection
|
|
// pool against a possibly-unreachable Agent.
|
|
factoryCalls.ShouldBe(0);
|
|
client.ProbeCallCount.ShouldBe(0);
|
|
client.CurrentCallCount.ShouldBe(0);
|
|
client.SampleCallCount.ShouldBe(0);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
}
|
|
|
|
[Fact]
|
|
public void Identity_is_the_constructor_arguments()
|
|
{
|
|
var (driver, _) = NewDriver();
|
|
|
|
driver.DriverInstanceId.ShouldBe("mt1");
|
|
driver.DriverType.ShouldBe("MTConnect");
|
|
}
|
|
|
|
// ---- initialize ----
|
|
|
|
[Fact]
|
|
public async Task Initialize_primes_index_and_reports_healthy()
|
|
{
|
|
var (driver, client) = NewDriver();
|
|
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
client.ProbeCallCount.ShouldBe(1);
|
|
client.CurrentCallCount.ShouldBe(1);
|
|
|
|
// The /current snapshot is what makes the driver able to answer a read at all.
|
|
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
|
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_records_the_agent_instance_id_and_the_probe_model()
|
|
{
|
|
var (driver, client) = NewDriver();
|
|
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
driver.AgentInstanceId.ShouldBe(client.Current.InstanceId);
|
|
driver.CachedProbeModel.ShouldNotBeNull();
|
|
driver.CachedProbeModel!.Devices.Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_stamps_last_successful_read_between_the_call_boundaries()
|
|
{
|
|
var (driver, _) = NewDriver();
|
|
|
|
var before = DateTime.UtcNow;
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
var after = DateTime.UtcNow;
|
|
|
|
var lastRead = driver.GetHealth().LastSuccessfulRead;
|
|
lastRead.ShouldNotBeNull();
|
|
lastRead!.Value.ShouldBeGreaterThanOrEqualTo(before);
|
|
lastRead.Value.ShouldBeLessThanOrEqualTo(after);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_faults_and_rethrows_when_probe_fails()
|
|
{
|
|
var (driver, client) = NewDriver();
|
|
client.ProbeFailure = new HttpRequestException("agent unreachable");
|
|
|
|
var thrown = await Should.ThrowAsync<HttpRequestException>(
|
|
() => driver.InitializeAsync("{}", CancellationToken.None));
|
|
|
|
thrown.Message.ShouldBe("agent unreachable");
|
|
|
|
var health = driver.GetHealth();
|
|
health.State.ShouldBe(DriverState.Faulted);
|
|
health.LastError.ShouldNotBeNull();
|
|
health.LastError!.ShouldContain("agent unreachable");
|
|
|
|
// No half-initialized object: the client it built must not survive the failure.
|
|
client.IsDisposed.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #517 — the failed state must be published BEFORE the client is disposed, not after.
|
|
/// <para>Every other test in this file asserts the state <em>once InitializeAsync has returned</em>,
|
|
/// by which time the window has closed. That is why the defect survived: the terminal value was
|
|
/// always right, and only the ordering was wrong. This samples <c>GetHealth()</c> from inside the
|
|
/// client's Dispose, which is the moment the driver stops being able to serve anything.</para>
|
|
/// <para>The window is reachable in production, which is what makes this worth pinning rather than
|
|
/// a theoretical tidy-up: <c>DriverInstanceActor</c> runs <c>InitializeAsync</c> on a
|
|
/// fire-and-forget <c>Task.Run</c>, so its health-poll tick keeps calling <c>GetHealth()</c> on the
|
|
/// actor thread for the whole duration of the teardown.</para>
|
|
/// <para>Falsifiable: remove the pre-teardown <c>WriteHealth</c> and the observed state reverts to
|
|
/// <see cref="DriverState.Initializing"/> — the stale value written on entry.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Initialize_publishes_the_failed_state_before_disposing_the_client()
|
|
{
|
|
var (driver, client) = NewDriver();
|
|
client.ProbeFailure = new HttpRequestException("agent unreachable");
|
|
|
|
DriverState? stateAtDisposal = null;
|
|
client.OnFirstDispose = () => stateAtDisposal = driver.GetHealth().State;
|
|
|
|
await Should.ThrowAsync<HttpRequestException>(
|
|
() => driver.InitializeAsync("{}", CancellationToken.None));
|
|
|
|
client.IsDisposed.ShouldBeTrue("the test is vacuous unless the disposal actually happened");
|
|
stateAtDisposal.ShouldNotBeNull();
|
|
stateAtDisposal.ShouldBe(DriverState.Faulted,
|
|
"health must already report the failure when the client goes away — a caller polling " +
|
|
"GetHealth() during the teardown must never see a state that outlives the transport");
|
|
|
|
// And the terminal write still lands, so the resting state is right too.
|
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_faults_when_probe_succeeds_but_the_priming_current_fails()
|
|
{
|
|
// Deliberate posture (documented on InitializeAsync): a driver whose /probe worked but whose
|
|
// priming /current did not has an EMPTY value index and no sample cursor. Reporting Healthy
|
|
// there would render every tag BadWaitingForInitialData forever while the dashboard shows a
|
|
// green driver — the #485 "a failure that looks like success" shape.
|
|
var (driver, client) = NewDriver();
|
|
client.CurrentFailure = new InvalidDataException("current unparseable");
|
|
|
|
await Should.ThrowAsync<InvalidDataException>(
|
|
() => driver.InitializeAsync("{}", CancellationToken.None));
|
|
|
|
client.ProbeCallCount.ShouldBe(1);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
driver.GetHealth().State.ShouldNotBe(DriverState.Healthy);
|
|
driver.CachedProbeModel.ShouldBeNull();
|
|
driver.ObservationIndex.Count.ShouldBe(0);
|
|
client.IsDisposed.ShouldBeTrue();
|
|
}
|
|
|
|
/// <summary>
|
|
/// arch-review 01/S-6: an operator-authorable <c>0</c> must never come to mean "wait
|
|
/// forever" (or "ask the Agent for nothing"). All four knobs share one
|
|
/// <c>RequirePositive</c> path, so all four are asserted — a guard that covered only the
|
|
/// one knob with a test is exactly how three of them would quietly lose the check.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Asserted through a FAKE client factory, so this proves the DRIVER validates rather than
|
|
/// leaning on the production client's own constructor guard.
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData("RequestTimeoutMs")]
|
|
[InlineData("HeartbeatMs")]
|
|
[InlineData("SampleIntervalMs")]
|
|
[InlineData("SampleCount")]
|
|
public async Task Initialize_rejects_a_non_positive_timing_knob_before_dialling(string knob)
|
|
{
|
|
var options = knob switch
|
|
{
|
|
"RequestTimeoutMs" => Opts(requestTimeoutMs: 0),
|
|
"HeartbeatMs" => Opts(heartbeatMs: 0),
|
|
"SampleIntervalMs" => Opts(sampleIntervalMs: 0),
|
|
"SampleCount" => Opts(sampleCount: 0),
|
|
_ => throw new ArgumentOutOfRangeException(nameof(knob), knob, "unhandled knob"),
|
|
};
|
|
|
|
var factoryCalls = 0;
|
|
var driver = new MTConnectDriver(options, "mt1", _ =>
|
|
{
|
|
factoryCalls++;
|
|
|
|
return CannedAgentClient.FromFixtures();
|
|
});
|
|
|
|
var thrown = await Should.ThrowAsync<ArgumentOutOfRangeException>(
|
|
() => driver.InitializeAsync("{}", CancellationToken.None));
|
|
|
|
// The message must name the offending key, or an operator cannot act on it.
|
|
thrown.Message.ShouldContain(knob);
|
|
factoryCalls.ShouldBe(0);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
}
|
|
|
|
// ---- the driverConfigJson argument ----
|
|
|
|
[Fact]
|
|
public async Task Initialize_honours_the_config_json_over_the_constructor_options()
|
|
{
|
|
// The IDriver contract: the driver serves the config document it is handed, not only its
|
|
// constructor options — ignoring it would turn an in-place config change into a silent no-op.
|
|
MTConnectDriverOptions? seen = null;
|
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
|
{
|
|
seen = o;
|
|
|
|
return CannedAgentClient.FromFixtures();
|
|
});
|
|
|
|
await driver.InitializeAsync(
|
|
"""{"agentUri":"http://other-agent:5001","deviceName":"VMC-3Axis"}""",
|
|
CancellationToken.None);
|
|
|
|
seen.ShouldNotBeNull();
|
|
seen!.AgentUri.ShouldBe("http://other-agent:5001");
|
|
seen.DeviceName.ShouldBe("VMC-3Axis");
|
|
}
|
|
|
|
/// <summary>
|
|
/// "Semantically empty" is decided by parsing, not by matching the literal two-character
|
|
/// spellings. A pretty-printer, a formatter, or a hand edit turns <c>{}</c> into <c>{ }</c>
|
|
/// or <c>{\n}</c> without changing its meaning — and under literal matching each of those
|
|
/// reached ParseOptions, failed the required-AgentUri check, and turned an empty document
|
|
/// into a cold-start fault or a rejected deployment.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("{}")]
|
|
[InlineData("{ }")]
|
|
[InlineData("{\n}")]
|
|
[InlineData(" {\r\n\t} ")]
|
|
[InlineData("[]")]
|
|
[InlineData("[ ]")]
|
|
[InlineData("null")]
|
|
[InlineData("")]
|
|
[InlineData(" ")]
|
|
public async Task Initialize_with_a_semantically_empty_config_body_keeps_the_constructor_options(string json)
|
|
{
|
|
MTConnectDriverOptions? seen = null;
|
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
|
{
|
|
seen = o;
|
|
|
|
return CannedAgentClient.FromFixtures();
|
|
});
|
|
|
|
await driver.InitializeAsync(json, CancellationToken.None);
|
|
|
|
seen.ShouldNotBeNull();
|
|
seen!.AgentUri.ShouldBe(AgentUri);
|
|
seen.Tags.Count.ShouldBe(2);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_treats_a_malformed_config_body_as_a_parse_error_not_as_empty()
|
|
{
|
|
// The other half of the emptiness rule: unreadable text must NOT fall through to "no config
|
|
// supplied" and silently start the driver on stale options.
|
|
var (driver, _) = NewDriver();
|
|
|
|
var thrown = await Should.ThrowAsync<InvalidOperationException>(
|
|
() => driver.InitializeAsync("""{"agentUri": """, CancellationToken.None));
|
|
|
|
thrown.Message.ShouldContain("not valid JSON");
|
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_faults_on_a_config_body_with_no_agent_uri()
|
|
{
|
|
var (driver, _) = NewDriver();
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
() => driver.InitializeAsync("""{"deviceName":"VMC-3Axis"}""", CancellationToken.None));
|
|
|
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_on_a_live_instance_with_an_unreadable_config_keeps_it_serving()
|
|
{
|
|
// One rule for both entry points: an unreadable document never destroys working state; it
|
|
// faults only a driver that had none. InitializeAsync IS reachable on a live instance
|
|
// (DriverInstanceActor re-initialises during Connecting/Reconnecting), so parsing must
|
|
// happen BEFORE the teardown — otherwise a bad edit kills a healthy client and the driver
|
|
// ends up in exactly the state ReinitializeAsync is written to avoid.
|
|
var (driver, client) = await InitializedDriverAsync();
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
() => driver.InitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None));
|
|
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri);
|
|
client.IsDisposed.ShouldBeFalse();
|
|
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialize_parses_authored_tags_out_of_the_config_json()
|
|
{
|
|
MTConnectDriverOptions? seen = null;
|
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
|
{
|
|
seen = o;
|
|
|
|
return CannedAgentClient.FromFixtures();
|
|
});
|
|
|
|
await driver.InitializeAsync(
|
|
"""
|
|
{"agentUri":"http://a:5000",
|
|
"tags":[{"fullName":"dev1_partcount","driverDataType":"Int32"}]}
|
|
""",
|
|
CancellationToken.None);
|
|
|
|
seen.ShouldNotBeNull();
|
|
seen!.Tags.Count.ShouldBe(1);
|
|
seen.Tags[0].FullName.ShouldBe("dev1_partcount");
|
|
|
|
// Enum-serialization trap (project-wide MEMORY): enum-carrying config fields are NAMES.
|
|
seen.Tags[0].DriverDataType.ShouldBe(DriverDataType.Int32);
|
|
}
|
|
|
|
// ---- reinitialize ----
|
|
|
|
[Fact]
|
|
public async Task Reinitialize_with_unchanged_endpoint_config_keeps_the_same_client()
|
|
{
|
|
var built = new List<CannedAgentClient>();
|
|
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
|
{
|
|
var c = CannedAgentClient.FromFixtures();
|
|
built.Add(c);
|
|
|
|
return c;
|
|
});
|
|
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
await driver.ReinitializeAsync(
|
|
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""",
|
|
CancellationToken.None);
|
|
|
|
// Config-only: the live connection pool survives, so no client churn...
|
|
built.Count.ShouldBe(1);
|
|
built[0].IsDisposed.ShouldBeFalse();
|
|
|
|
// ...but everything DERIVED from config is genuinely rebuilt against the new document.
|
|
built[0].ProbeCallCount.ShouldBe(2);
|
|
built[0].CurrentCallCount.ShouldBe(2);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reinitialize_with_a_changed_agent_uri_tears_down_and_rebuilds_the_client()
|
|
{
|
|
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
|
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
|
{
|
|
var c = CannedAgentClient.FromFixtures();
|
|
built.Add((o, c));
|
|
|
|
return c;
|
|
});
|
|
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
await driver.ReinitializeAsync(
|
|
"""{"agentUri":"http://relocated-agent:5000"}""", CancellationToken.None);
|
|
|
|
// A re-pointed driver that kept the old client would keep reading the OLD machine while the
|
|
// deployment reports success.
|
|
built.Count.ShouldBe(2);
|
|
built[0].Client.IsDisposed.ShouldBeTrue();
|
|
built[1].Options.AgentUri.ShouldBe("http://relocated-agent:5000");
|
|
built[1].Client.IsDisposed.ShouldBeFalse();
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reinitialize_with_a_changed_device_scope_rebuilds_the_client()
|
|
{
|
|
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
|
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
|
{
|
|
var c = CannedAgentClient.FromFixtures();
|
|
built.Add((o, c));
|
|
|
|
return c;
|
|
});
|
|
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
await driver.ReinitializeAsync(
|
|
$$"""{"agentUri":"{{AgentUri}}","deviceName":"VMC-3Axis"}""", CancellationToken.None);
|
|
|
|
// DeviceName is baked into every request URI at client construction.
|
|
built.Count.ShouldBe(2);
|
|
built[0].Client.IsDisposed.ShouldBeTrue();
|
|
built[1].Options.DeviceName.ShouldBe("VMC-3Axis");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reinitialize_with_changed_request_knobs_rebuilds_the_client()
|
|
{
|
|
// RequestTimeoutMs / HeartbeatMs / SampleIntervalMs / SampleCount are all read by the client
|
|
// CONSTRUCTOR (deadlines, watchdog window, /sample query string). Keeping the old client
|
|
// because the URI happened not to change would silently discard the operator's edit.
|
|
var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
|
|
var driver = new MTConnectDriver(Opts(), "mt1", o =>
|
|
{
|
|
var c = CannedAgentClient.FromFixtures();
|
|
built.Add((o, c));
|
|
|
|
return c;
|
|
});
|
|
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
await driver.ReinitializeAsync(
|
|
$$"""{"agentUri":"{{AgentUri}}","heartbeatMs":2500}""", CancellationToken.None);
|
|
|
|
built.Count.ShouldBe(2);
|
|
built[1].Options.HeartbeatMs.ShouldBe(2500);
|
|
built[0].Client.IsDisposed.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reinitialize_before_initialize_behaves_as_initialize()
|
|
{
|
|
var (driver, client) = NewDriver();
|
|
|
|
await driver.ReinitializeAsync("{}", CancellationToken.None);
|
|
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
client.ProbeCallCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reinitialize_that_fails_leaves_the_driver_faulted_not_healthy()
|
|
{
|
|
var built = new List<CannedAgentClient>();
|
|
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
|
{
|
|
var c = CannedAgentClient.FromFixtures();
|
|
built.Add(c);
|
|
|
|
return c;
|
|
});
|
|
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
built[0].CurrentFailure = new HttpRequestException("agent went away");
|
|
|
|
// Config-only shape (only the tag list changes), so the failing leg is the re-prime against
|
|
// the client that is already live — the path a rebuild would otherwise mask.
|
|
await Should.ThrowAsync<HttpRequestException>(
|
|
() => driver.ReinitializeAsync(
|
|
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos"}]}""", CancellationToken.None));
|
|
|
|
built.Count.ShouldBe(1);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Faulted);
|
|
driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("agent went away");
|
|
|
|
// A failed refresh must not leave a live client nobody owns.
|
|
built[0].IsDisposed.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving()
|
|
{
|
|
// Blast radius: an unparseable in-place config edit fails the reinitialize call; it must
|
|
// not down a driver that is happily serving its previous, valid configuration.
|
|
var (driver, client) = await InitializedDriverAsync();
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
() => driver.ReinitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None));
|
|
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri);
|
|
client.IsDisposed.ShouldBeFalse();
|
|
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
|
}
|
|
|
|
// ---- shutdown ----
|
|
|
|
[Fact]
|
|
public async Task Shutdown_disposes_the_client_and_drops_the_served_state()
|
|
{
|
|
var (driver, client) = await InitializedDriverAsync();
|
|
var lastRead = driver.GetHealth().LastSuccessfulRead;
|
|
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
|
|
client.DisposeCount.ShouldBe(1);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
|
|
// Retained: it is diagnostic ("when did we last hear from the Agent"), not served data.
|
|
driver.GetHealth().LastSuccessfulRead.ShouldBe(lastRead);
|
|
|
|
// Dropped: values from a torn-down connection must never keep reading Good.
|
|
driver.ObservationIndex.Count.ShouldBe(0);
|
|
driver.CachedProbeModel.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Shutdown_is_idempotent()
|
|
{
|
|
var (driver, client) = await InitializedDriverAsync();
|
|
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
|
|
client.DisposeCount.ShouldBe(1);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Shutdown_before_initialize_is_a_no_op()
|
|
{
|
|
var (driver, client) = NewDriver();
|
|
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
|
|
client.DisposeCount.ShouldBe(0);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
}
|
|
|
|
// ---- dropping the probe cache ----
|
|
//
|
|
// These used to drive FlushOptionalCachesAsync. Gitea #525 deleted it as an unconsumed IDriver
|
|
// member, along with GetMemoryFootprint and the footprint test that only existed to assert on
|
|
// it. What survives is InvalidateProbeModel — the Agent-restart drop, which is the only thing
|
|
// that empties this cache in production, and which has the same retired-session guard and the
|
|
// same "browse must not be left permanently empty" obligation.
|
|
|
|
[Fact]
|
|
public async Task Dropping_the_probe_cache_keeps_the_observation_index()
|
|
{
|
|
var (driver, _) = NewDriver();
|
|
await FixtureCleanup.RunAsync(async () =>
|
|
{
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
driver.InvalidateProbeModel();
|
|
|
|
driver.CachedProbeModel.ShouldBeNull();
|
|
|
|
// The index is required for correctness (it IS the read surface) — never flushable.
|
|
driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
|
|
driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
|
|
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
}, () => driver.ShutdownAsync(CancellationToken.None),
|
|
failure => output.WriteLine($"MTConnect cache drop cleanup failed: {failure}"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Body_failure_after_probe_scheduler_enters_stops_probe_and_disposes_client()
|
|
{
|
|
var client = CannedAgentClient.FromFixtures();
|
|
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var released = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var driver = new MTConnectDriver(Opts(), "mt1", _ => client,
|
|
probeScheduler: async (_, ct) =>
|
|
{
|
|
entered.TrySetResult();
|
|
try { await released.Task.WaitAsync(ct); }
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
cancelled.TrySetResult();
|
|
throw;
|
|
}
|
|
});
|
|
Task? probe = null;
|
|
var sentinel = new InvalidOperationException("body sentinel");
|
|
try
|
|
{
|
|
var actual = await Should.ThrowAsync<InvalidOperationException>(() => FixtureCleanup.RunAsync(async () =>
|
|
{
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
|
probe = driver.ProbeLoopTask;
|
|
_ = probe.ShouldNotBeNull();
|
|
probe!.IsCompleted.ShouldBeFalse();
|
|
client.IsDisposed.ShouldBeFalse();
|
|
throw sentinel;
|
|
}, () => driver.ShutdownAsync(CancellationToken.None),
|
|
failure => output.WriteLine($"MTConnect probe fixture cleanup failed: {failure}")));
|
|
actual.ShouldBeSameAs(sentinel);
|
|
await cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
|
probe!.IsCompleted.ShouldBeTrue();
|
|
client.DisposeCount.ShouldBe(1);
|
|
}
|
|
finally { released.TrySetResult(); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Initialization_failure_disposes_the_client_inside_fixture_boundary()
|
|
{
|
|
var client = CannedAgentClient.FromFixtures();
|
|
var failure = new HttpRequestException("current unavailable");
|
|
client.CurrentFailure = failure;
|
|
var driver = new MTConnectDriver(Opts(), "mt1", _ => client);
|
|
var actual = await Should.ThrowAsync<HttpRequestException>(() => FixtureCleanup.RunAsync(
|
|
() => driver.InitializeAsync("{}", CancellationToken.None),
|
|
() => driver.ShutdownAsync(CancellationToken.None),
|
|
secondary => output.WriteLine($"MTConnect init fixture cleanup failed: {secondary}")));
|
|
actual.ShouldBeSameAs(failure);
|
|
client.DisposeCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dropping_the_cache_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
|
|
{
|
|
// Task 12's DiscoverAsync reads the probe model. If dropping it left no way back, an Agent
|
|
// restart would permanently disable browse on that driver instance.
|
|
var (driver, client) = await InitializedDriverAsync();
|
|
driver.InvalidateProbeModel();
|
|
|
|
client.ProbeCallCount.ShouldBe(1);
|
|
|
|
var model = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
|
|
|
|
model.Devices.Count.ShouldBe(1);
|
|
client.ProbeCallCount.ShouldBe(2);
|
|
driver.CachedProbeModel.ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Probe_model_is_served_from_cache_while_it_is_warm()
|
|
{
|
|
var (driver, client) = await InitializedDriverAsync();
|
|
|
|
_ = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
|
|
|
|
client.ProbeCallCount.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Fetching_the_probe_model_before_initialize_is_rejected_rather_than_silently_empty()
|
|
{
|
|
var (driver, _) = NewDriver();
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
() => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Fetching_the_probe_model_after_shutdown_is_rejected()
|
|
{
|
|
var (driver, _) = await InitializedDriverAsync();
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
() => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
|
|
}
|
|
|
|
// ---- lock-free session snapshot: the probe fetch races the lifecycle ----
|
|
|
|
[Fact]
|
|
public async Task A_shutdown_landing_mid_fetch_surfaces_as_not_connected_not_as_object_disposed()
|
|
{
|
|
// Browse is deliberately lock-free (it awaits the network; holding the lifecycle lock would
|
|
// let it stall a shutdown for a whole RequestTimeoutMs), so a fetch CAN outlive the client
|
|
// it captured. What must not happen is a raw ObjectDisposedException escaping into browse —
|
|
// the caller already handles "not connected" and should have exactly one failure mode.
|
|
var (driver, client) = await InitializedDriverAsync();
|
|
driver.InvalidateProbeModel();
|
|
|
|
// Held locally: the gate is one-shot, so the client has already cleared its own reference by
|
|
// the time the request is parked on it.
|
|
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
client.ProbeGate = gate;
|
|
var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None);
|
|
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
client.IsDisposed.ShouldBeTrue();
|
|
gate.TrySetResult();
|
|
|
|
var thrown = await Should.ThrowAsync<InvalidOperationException>(() => inFlight);
|
|
thrown.InnerException.ShouldBeOfType<ObjectDisposedException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_fetch_that_completes_after_a_reinitialize_does_not_overwrite_the_newer_cache()
|
|
{
|
|
// The probe cache is re-published only onto the session it was fetched against. Without
|
|
// that check, a slow browse would install a device model belonging to a superseded
|
|
// configuration over the one the re-initialize just established — stale browse output that
|
|
// nothing would ever correct.
|
|
var built = new List<CannedAgentClient>();
|
|
var driver = new MTConnectDriver(Opts(), "mt1", _ =>
|
|
{
|
|
var c = CannedAgentClient.FromFixtures();
|
|
built.Add(c);
|
|
|
|
return c;
|
|
});
|
|
|
|
await driver.InitializeAsync("{}", CancellationToken.None);
|
|
driver.InvalidateProbeModel();
|
|
|
|
var client = built[0];
|
|
var staleModel = client.Probe;
|
|
|
|
// Park a fetch on the OLD (about to be superseded) session; it captures staleModel now.
|
|
// Held locally — the gate is one-shot and clears the client's own reference when it parks.
|
|
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
client.ProbeGate = gate;
|
|
var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None);
|
|
|
|
// A config-only re-initialize: same client (so nothing is disposed), brand-new session.
|
|
var freshModel = new MTConnectProbeModel(staleModel.Devices);
|
|
client.Probe = freshModel;
|
|
await driver.ReinitializeAsync(
|
|
$$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""",
|
|
CancellationToken.None);
|
|
|
|
built.Count.ShouldBe(1);
|
|
driver.CachedProbeModel.ShouldBeSameAs(freshModel);
|
|
|
|
// Now let the stale fetch finish. It still returns what the Agent gave it...
|
|
gate.TrySetResult();
|
|
(await inFlight).ShouldBeSameAs(staleModel);
|
|
|
|
// ...but it must not have published that over the newer session's cache.
|
|
driver.CachedProbeModel.ShouldBeSameAs(freshModel);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Dropping_the_cache_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session()
|
|
{
|
|
// The /sample pump can see an Agent restart at any moment, so a drop arriving after the
|
|
// driver was torn down is ordinary, not exotic. It must observe the retired session and do
|
|
// nothing — publishing a "dropped" copy of it would reinstate a session holding an
|
|
// already-disposed client for every later caller.
|
|
//
|
|
// NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. The drop
|
|
// has no await point, so nothing can be interleaved between its read and its publish from a
|
|
// single-threaded test; the CAS is defence-in-depth against a genuinely concurrent
|
|
// lifecycle call and is deliberately claimed as such rather than as tested behaviour. The
|
|
// fetch-side CAS, which does have an await point, is covered by the test above.
|
|
var (driver, client) = await InitializedDriverAsync();
|
|
|
|
await driver.ShutdownAsync(CancellationToken.None);
|
|
driver.InvalidateProbeModel();
|
|
|
|
driver.CachedProbeModel.ShouldBeNull();
|
|
driver.GetHealth().State.ShouldBe(DriverState.Unknown);
|
|
client.DisposeCount.ShouldBe(1);
|
|
|
|
await Should.ThrowAsync<InvalidOperationException>(
|
|
() => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
|
|
}
|
|
}
|