@@ -0,0 +1,84 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-C5 — integration coverage for the post-<c>OpenAsync</c> pre-flight
|
||||
/// PUT/GET enablement probe. Snap7 always allows reads (no PUT/GET gating
|
||||
/// in the simulator), so the integration scope is limited to the happy
|
||||
/// path: pre-flight succeeds against the seeded MW0/DBW0 fingerprint and
|
||||
/// the driver reaches <see cref="DriverState.Healthy"/>. The "PUT/GET
|
||||
/// disabled" failure path is unit-tested via
|
||||
/// <c>S7PreflightClassifier</c> and documented as a follow-up live-firmware
|
||||
/// test in <c>docs/drivers/S7-Test-Fixture.md</c>.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500PreflightTests(Snap7ServerFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Driver_preflight_passes_when_probe_address_seeded()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Use the standard S7-1500 profile — DB1.DBW0 / DB1.DBW10 / etc are seeded
|
||||
// by the snap7 profile so the default MW0 probe (or DB1.DBW0 fallback)
|
||||
// exists at read time.
|
||||
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
// Override the probe loop knob set by S7_1500Profile: the loop stays
|
||||
// disabled, but Probe.ProbeAddress + SkipPreflight are what RunPreflightAsync
|
||||
// consults. ProbeAddress defaults to MW0 which python-snap7 zeros at startup;
|
||||
// any successful read (zeros included) satisfies the pre-flight.
|
||||
var preflightOptions = new S7DriverOptions
|
||||
{
|
||||
Host = options.Host,
|
||||
Port = options.Port,
|
||||
CpuType = options.CpuType,
|
||||
Rack = options.Rack,
|
||||
Slot = options.Slot,
|
||||
Timeout = options.Timeout,
|
||||
Tags = options.Tags,
|
||||
// ProbeAddress = "MW0" (default); SkipPreflight = false (default).
|
||||
// Background probe loop disabled to avoid mailbox contention with the test.
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
};
|
||||
|
||||
await using var drv = new S7Driver(preflightOptions, driverInstanceId: "s7-preflight-pass");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// If pre-flight tripped the typed exception, InitializeAsync would have thrown
|
||||
// before reaching this line. Healthy state proves the probe succeeded.
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy,
|
||||
"pre-flight probe must succeed against the seeded snap7 fingerprint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_preflight_skipped_when_SkipPreflight_set()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Skipping the pre-flight is opt-in. The driver must still reach Healthy state
|
||||
// because the connect path itself succeeds; the only thing different is that no
|
||||
// probe read fires before _health flips.
|
||||
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
|
||||
var skipOptions = new S7DriverOptions
|
||||
{
|
||||
Host = options.Host,
|
||||
Port = options.Port,
|
||||
CpuType = options.CpuType,
|
||||
Rack = options.Rack,
|
||||
Slot = options.Slot,
|
||||
Timeout = options.Timeout,
|
||||
Tags = options.Tags,
|
||||
Probe = new S7ProbeOptions { Enabled = false, SkipPreflight = true },
|
||||
};
|
||||
|
||||
await using var drv = new S7Driver(skipOptions, driverInstanceId: "s7-preflight-skipped");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
}
|
||||
220
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PreflightTests.cs
Normal file
220
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7PreflightTests.cs
Normal file
@@ -0,0 +1,220 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using S7NetErrorCode = global::S7.Net.ErrorCode;
|
||||
using S7NetPlcException = global::S7.Net.PlcException;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR-S7-C5 — unit coverage for the post-<c>OpenAsync</c> pre-flight PUT/GET
|
||||
/// enablement probe and its typed exception. The classifier branch tests run
|
||||
/// without any network. Driver-level tests reach for unreachable IPs to drive
|
||||
/// the "pre-flight skipped because OpenAsync failed" lifecycle path.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7PreflightTests
|
||||
{
|
||||
// ---- S7PutGetDisabledException ----
|
||||
|
||||
[Fact]
|
||||
public void Typed_exception_carries_the_probe_address()
|
||||
{
|
||||
var ex = new S7PutGetDisabledException("MW0");
|
||||
ex.ProbeAddress.ShouldBe("MW0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typed_exception_message_names_TIA_Portal_fix_path()
|
||||
{
|
||||
var ex = new S7PutGetDisabledException("DB1.DBW0");
|
||||
ex.Message.ShouldContain("DB1.DBW0");
|
||||
ex.Message.ShouldContain("PUT/GET");
|
||||
ex.Message.ShouldContain("TIA Portal");
|
||||
ex.Message.ShouldContain("Permit access");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Typed_exception_preserves_inner_for_diagnostics()
|
||||
{
|
||||
var inner = new InvalidOperationException("simulated");
|
||||
var ex = new S7PutGetDisabledException("MW0", inner);
|
||||
ex.InnerException.ShouldBeSameAs(inner);
|
||||
}
|
||||
|
||||
// ---- S7PreflightClassifier.IsPutGetDisabled ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7NetErrorCode.WrongCPU_Type, true)]
|
||||
[InlineData(S7NetErrorCode.ReadData, true)]
|
||||
[InlineData(S7NetErrorCode.NoError, false)]
|
||||
[InlineData(S7NetErrorCode.ConnectionError, false)]
|
||||
[InlineData(S7NetErrorCode.IPAddressNotAvailable, false)]
|
||||
[InlineData(S7NetErrorCode.WrongVarFormat, false)]
|
||||
[InlineData(S7NetErrorCode.WrongNumberReceivedBytes, false)]
|
||||
[InlineData(S7NetErrorCode.SendData, false)]
|
||||
[InlineData(S7NetErrorCode.WriteData, false)]
|
||||
public void Classifier_matches_only_PUT_GET_disabled_error_codes(S7NetErrorCode code, bool expected)
|
||||
{
|
||||
var pex = new S7NetPlcException(code);
|
||||
S7PreflightClassifier.IsPutGetDisabled(pex).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classifier_returns_false_for_null_exception()
|
||||
{
|
||||
S7PreflightClassifier.IsPutGetDisabled(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- S7ProbeOptions defaults ----
|
||||
|
||||
[Fact]
|
||||
public void Default_probe_address_is_MW0()
|
||||
{
|
||||
// Documented default — the convention all the docs / CLI examples reference.
|
||||
new S7ProbeOptions().ProbeAddress.ShouldBe("MW0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_skip_preflight_is_false()
|
||||
{
|
||||
// Pre-flight runs by default. Operators must opt in to skip.
|
||||
new S7ProbeOptions().SkipPreflight.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Probe_address_can_be_null_to_skip_preflight()
|
||||
{
|
||||
var probe = new S7ProbeOptions { ProbeAddress = null };
|
||||
probe.ProbeAddress.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- DTO JSON round-trip preserves SkipPreflight ----
|
||||
|
||||
[Fact]
|
||||
public void JSON_round_trip_preserves_SkipPreflight_true()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"Probe": { "Enabled": false, "ProbeAddress": "DB1.DBW0", "SkipPreflight": true }
|
||||
}
|
||||
""";
|
||||
// Hit the CreateInstance flow exactly as the bootstrapper does.
|
||||
// Use reflection because the DTO + entry method are internal.
|
||||
var ext = typeof(S7DriverFactoryExtensions);
|
||||
var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic)
|
||||
.ShouldNotBeNull("S7DriverFactoryExtensions.CreateInstance entry method must exist");
|
||||
var driver = create.Invoke(null, ["s7-skipflight-test", json]).ShouldBeOfType<S7Driver>();
|
||||
|
||||
// Read the resolved options off the driver via reflection; SkipPreflight must
|
||||
// have flowed through the JSON → DTO → S7ProbeOptions path unchanged.
|
||||
var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.ShouldNotBeNull();
|
||||
var opts = optsField.GetValue(driver).ShouldBeOfType<S7DriverOptions>();
|
||||
opts.Probe.SkipPreflight.ShouldBeTrue("DTO -> options must round-trip SkipPreflight=true");
|
||||
opts.Probe.ProbeAddress.ShouldBe("DB1.DBW0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JSON_round_trip_defaults_SkipPreflight_to_false_when_omitted()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"Probe": { "ProbeAddress": "MW0" }
|
||||
}
|
||||
""";
|
||||
var ext = typeof(S7DriverFactoryExtensions);
|
||||
var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic)
|
||||
.ShouldNotBeNull();
|
||||
var driver = create.Invoke(null, ["s7-default-test", json]).ShouldBeOfType<S7Driver>();
|
||||
var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.ShouldNotBeNull();
|
||||
var opts = optsField.GetValue(driver).ShouldBeOfType<S7DriverOptions>();
|
||||
opts.Probe.SkipPreflight.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JSON_empty_probe_address_means_skip()
|
||||
{
|
||||
// Explicit empty string → no probe wire-up; null in the runtime options means
|
||||
// "no probe address configured" so RunPreflightAsync skips.
|
||||
const string json = """
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"Probe": { "ProbeAddress": "" }
|
||||
}
|
||||
""";
|
||||
var ext = typeof(S7DriverFactoryExtensions);
|
||||
var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic)
|
||||
.ShouldNotBeNull();
|
||||
var driver = create.Invoke(null, ["s7-noprobe-test", json]).ShouldBeOfType<S7Driver>();
|
||||
var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.ShouldNotBeNull();
|
||||
var opts = optsField.GetValue(driver).ShouldBeOfType<S7DriverOptions>();
|
||||
opts.Probe.ProbeAddress.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JSON_omitted_probe_object_keeps_default_MW0_address()
|
||||
{
|
||||
// No Probe object at all — existing configs from S7-A/B PRs must keep working.
|
||||
const string json = """
|
||||
{ "Host": "10.0.0.50" }
|
||||
""";
|
||||
var ext = typeof(S7DriverFactoryExtensions);
|
||||
var create = ext.GetMethod("CreateInstance", BindingFlags.Static | BindingFlags.NonPublic)
|
||||
.ShouldNotBeNull();
|
||||
var driver = create.Invoke(null, ["s7-omittedprobe", json]).ShouldBeOfType<S7Driver>();
|
||||
var optsField = typeof(S7Driver).GetField("_options", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.ShouldNotBeNull();
|
||||
var opts = optsField.GetValue(driver).ShouldBeOfType<S7DriverOptions>();
|
||||
opts.Probe.ProbeAddress.ShouldBe("MW0");
|
||||
opts.Probe.SkipPreflight.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- Driver-level lifecycle around the pre-flight probe ----
|
||||
//
|
||||
// We can't drive the "PUT/GET disabled" wire path without a fake S7 server, so
|
||||
// these tests verify that the SkipPreflight / null-ProbeAddress short-circuits
|
||||
// are honoured by checking the lifecycle never reaches OpenAsync against an
|
||||
// unreachable host (otherwise we'd see a Connection Error before the probe
|
||||
// path runs at all). The classifier branch above is the unit coverage for the
|
||||
// exception-mapping decision.
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_with_SkipPreflight_still_throws_on_unreachable_host()
|
||||
{
|
||||
// Sanity: skipping the pre-flight does NOT skip OpenAsync. An unreachable host
|
||||
// still flips the driver to Faulted via the existing Connection-Error path.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Probe = new S7ProbeOptions { Enabled = false, SkipPreflight = true },
|
||||
};
|
||||
await using var drv = new S7Driver(opts, "s7-skip-unreach");
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_with_null_ProbeAddress_still_throws_on_unreachable_host()
|
||||
{
|
||||
// Same sanity check for the "ProbeAddress = null" path.
|
||||
var opts = new S7DriverOptions
|
||||
{
|
||||
Host = "192.0.2.1",
|
||||
Timeout = TimeSpan.FromMilliseconds(250),
|
||||
Probe = new S7ProbeOptions { Enabled = false, ProbeAddress = null },
|
||||
};
|
||||
await using var drv = new S7Driver(opts, "s7-null-addr-unreach");
|
||||
await Should.ThrowAsync<Exception>(async () =>
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user