docs: backfill XML documentation across 756 files
v2-ci / build (push) Failing after 1m43s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

Adds <summary>, <param>, <typeparam>, and <inheritdoc/> tags to public
members surfaced by commentchecker — resolves 5,847 of 5,869 issues
(99.6%) across three /fixdocs passes.
This commit is contained in:
Joseph Doherty
2026-05-28 08:10:17 -04:00
parent f9fc7dd2e1
commit 64e3fbe035
756 changed files with 9876 additions and 96 deletions
@@ -18,11 +18,20 @@ public sealed class AbCipCommandBaseTests
[CliFx.Attributes.Command("test")]
private sealed class TestableCommand : AbCipCommandBase
{
/// <summary>
/// Invokes the protected <see cref="AbCipCommandBase.BuildOptions"/> method.
/// </summary>
/// <param name="tags">The tags to pass to BuildOptions.</param>
/// <returns>The configured driver options.</returns>
public AbCipDriverOptions InvokeBuildOptions(IReadOnlyList<AbCipTagDefinition> tags)
=> BuildOptions(tags);
/// <summary>
/// Gets the protected <see cref="AbCipCommandBase.DriverInstanceId"/> property.
/// </summary>
public string InvokeDriverInstanceId => DriverInstanceId;
/// <inheritdoc />
public override ValueTask ExecuteAsync(CliFx.Infrastructure.IConsole console)
=> ValueTask.CompletedTask;
}
@@ -34,6 +43,9 @@ public sealed class AbCipCommandBaseTests
DataType: AbCipDataType.DInt,
Writable: false);
/// <summary>
/// Verifies that BuildOptions disables probe to prevent racing operator reads.
/// </summary>
[Fact]
public void BuildOptions_disables_probe_so_cli_does_not_race_operator_reads()
{
@@ -49,6 +61,9 @@ public sealed class AbCipCommandBaseTests
options.Probe.Enabled.ShouldBeFalse();
}
/// <summary>
/// Verifies that BuildOptions disables controller browse.
/// </summary>
[Fact]
public void BuildOptions_disables_controller_browse()
{
@@ -64,6 +79,9 @@ public sealed class AbCipCommandBaseTests
options.EnableControllerBrowse.ShouldBeFalse();
}
/// <summary>
/// Verifies that BuildOptions disables alarm projection.
/// </summary>
[Fact]
public void BuildOptions_disables_alarm_projection()
{
@@ -79,6 +97,9 @@ public sealed class AbCipCommandBaseTests
options.EnableAlarmProjection.ShouldBeFalse();
}
/// <summary>
/// Verifies that BuildOptions produces one device with gateway family and derived name.
/// </summary>
[Fact]
public void BuildOptions_produces_one_device_with_gateway_family_and_derived_name()
{
@@ -98,6 +119,9 @@ public sealed class AbCipCommandBaseTests
device.DeviceName.ShouldBe("cli-CompactLogix");
}
/// <summary>
/// Verifies that BuildOptions passes the supplied tag list verbatim.
/// </summary>
[Fact]
public void BuildOptions_passes_supplied_tag_list_verbatim()
{
@@ -116,6 +140,9 @@ public sealed class AbCipCommandBaseTests
options.Tags[1].Name.ShouldBe("t2");
}
/// <summary>
/// Verifies that BuildOptions carries TimeoutMs through to Timeout.
/// </summary>
[Fact]
public void BuildOptions_carries_TimeoutMs_through_to_Timeout()
{
@@ -131,6 +158,9 @@ public sealed class AbCipCommandBaseTests
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
}
/// <summary>
/// Verifies that DriverInstanceId embeds gateway for log disambiguation.
/// </summary>
[Fact]
public void DriverInstanceId_embeds_gateway_for_log_disambiguation()
{
@@ -143,6 +173,9 @@ public sealed class AbCipCommandBaseTests
cmd.InvokeDriverInstanceId.ShouldBe("abcip-cli-ab://10.0.0.5/1,0");
}
/// <summary>
/// Verifies that Timeout setter is inert and does not silently swallow assignments.
/// </summary>
[Fact]
public void Timeout_setter_is_inert_and_does_not_silently_swallow_assignments()
{
@@ -156,6 +189,10 @@ public sealed class AbCipCommandBaseTests
});
}
/// <summary>
/// Verifies that Timeout getter throws CommandException when TimeoutMs is non-positive.
/// </summary>
/// <param name="badMs">A non-positive timeout value to test.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
@@ -173,6 +210,9 @@ public sealed class AbCipCommandBaseTests
ex.Message.ShouldContain("--timeout-ms");
}
/// <summary>
/// Verifies that RejectStructure throws for Structure DataType.
/// </summary>
[Fact]
public void RejectStructure_throws_for_Structure_DataType()
{
@@ -181,6 +221,10 @@ public sealed class AbCipCommandBaseTests
ex.Message.ShouldContain("Structure");
}
/// <summary>
/// Verifies that RejectStructure passes for atomic data types.
/// </summary>
/// <param name="type">The atomic data type to test.</param>
[Theory]
[InlineData(AbCipDataType.DInt)]
[InlineData(AbCipDataType.Bool)]
@@ -12,6 +12,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class SubscribeCommandIntervalTests
{
/// <summary>Verifies that validate interval rejects non-positive milliseconds.</summary>
/// <param name="badMs">A non-positive interval value that should be rejected.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
@@ -23,6 +25,8 @@ public sealed class SubscribeCommandIntervalTests
ex.Message.ShouldContain("--interval-ms");
}
/// <summary>Verifies that validate interval accepts positive milliseconds.</summary>
/// <param name="goodMs">A positive interval value that should be accepted.</param>
[Theory]
[InlineData(1)]
[InlineData(250)]
@@ -11,6 +11,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class WriteCommandParseValueTests
{
/// <summary>Verifies ParseValue accepts common boolean aliases.</summary>
/// <param name="raw">Input string to parse.</param>
/// <param name="expected">Expected boolean value.</param>
[Theory]
[InlineData("true", true)]
[InlineData("0", false)]
@@ -21,6 +24,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue(raw, AbCipDataType.Bool).ShouldBe(expected);
}
/// <summary>Verifies ParseValue rejects invalid boolean input.</summary>
[Fact]
public void ParseValue_Bool_rejects_garbage()
{
@@ -28,6 +32,7 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("maybe", AbCipDataType.Bool));
}
/// <summary>Verifies ParseValue correctly widens SInt to signed byte.</summary>
[Fact]
public void ParseValue_SInt_widens_to_sbyte()
{
@@ -35,12 +40,14 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("127", AbCipDataType.SInt).ShouldBe((sbyte)127);
}
/// <summary>Verifies ParseValue correctly parses signed 16-bit integers.</summary>
[Fact]
public void ParseValue_Int_signed_16bit()
{
WriteCommand.ParseValue("-32768", AbCipDataType.Int).ShouldBe((short)-32768);
}
/// <summary>Verifies ParseValue handles DInt and Dt data types as 32-bit integers.</summary>
[Fact]
public void ParseValue_DInt_and_Dt_both_land_on_int()
{
@@ -48,12 +55,14 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("1234567", AbCipDataType.Dt).ShouldBeOfType<int>();
}
/// <summary>Verifies ParseValue correctly parses 64-bit long integers.</summary>
[Fact]
public void ParseValue_LInt_64bit()
{
WriteCommand.ParseValue("9223372036854775807", AbCipDataType.LInt).ShouldBe(long.MaxValue);
}
/// <summary>Verifies ParseValue respects unsigned type bounds.</summary>
[Fact]
public void ParseValue_unsigned_range_respects_bounds()
{
@@ -62,24 +71,28 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("4294967295", AbCipDataType.UDInt).ShouldBeOfType<uint>();
}
/// <summary>Verifies ParseValue correctly parses floating-point with invariant culture.</summary>
[Fact]
public void ParseValue_Real_invariant_culture_decimal()
{
WriteCommand.ParseValue("3.14", AbCipDataType.Real).ShouldBe(3.14f);
}
/// <summary>Verifies ParseValue correctly handles double-precision floating-point.</summary>
[Fact]
public void ParseValue_LReal_handles_double_precision()
{
WriteCommand.ParseValue("2.718281828", AbCipDataType.LReal).ShouldBeOfType<double>();
}
/// <summary>Verifies ParseValue passes through string values unchanged.</summary>
[Fact]
public void ParseValue_String_passthrough()
{
WriteCommand.ParseValue("hello logix", AbCipDataType.String).ShouldBe("hello logix");
}
/// <summary>Verifies ParseValue throws for non-numeric input on numeric types.</summary>
[Fact]
public void ParseValue_non_numeric_for_numeric_types_throws_CommandException()
{
@@ -87,6 +100,7 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("xyz", AbCipDataType.DInt));
}
/// <summary>Verifies ParseValue throws for out-of-range numeric values.</summary>
[Fact]
public void ParseValue_out_of_range_throws_CommandException()
{
@@ -95,6 +109,9 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("999", AbCipDataType.SInt));
}
/// <summary>Verifies ParseValue exception messages include the input and data type for context.</summary>
/// <param name="raw">Input string to parse.</param>
/// <param name="type">Data type to parse into.</param>
[Theory]
[InlineData("12x", AbCipDataType.Int)]
[InlineData("3.14", AbCipDataType.DInt)]
@@ -108,6 +125,10 @@ public sealed class WriteCommandParseValueTests
ex.Message.ShouldContain(type.ToString());
}
/// <summary>Verifies SynthesiseTagName preserves the tag path verbatim in the output.</summary>
/// <param name="path">Tag path.</param>
/// <param name="type">Data type.</param>
/// <param name="expected">Expected synthesized tag name.</param>
[Theory]
[InlineData("Motor01_Speed", AbCipDataType.Real, "Motor01_Speed:Real")]
[InlineData("Program:Main.Counter", AbCipDataType.DInt, "Program:Main.Counter:DInt")]
@@ -27,9 +27,16 @@ public sealed class BuildOptionsTests
[Command("test-build-options")]
private sealed class TestCommand : AbLegacyCommandBase
{
/// <summary>Builds driver options from the command's configuration and the provided tags.</summary>
/// <param name="tags">The tag definitions to include in the options.</param>
/// <returns>Configured driver options.</returns>
public AbLegacyDriverOptions Build(IReadOnlyList<AbLegacyTagDefinition> tags)
=> BuildOptions(tags);
/// <summary>Not used; this test command is for BuildOptions inspection only.</summary>
/// <param name="console">Not used.</param>
/// <returns>Not used.</returns>
/// <exception cref="NotSupportedException">Always thrown; this method should not be called.</exception>
public override System.Threading.Tasks.ValueTask ExecuteAsync(IConsole console)
=> throw new NotSupportedException("TestCommand is for BuildOptions inspection only.");
}
@@ -50,6 +57,7 @@ public sealed class BuildOptionsTests
Writable: true),
];
/// <summary>Verifies that probe is disabled for CLI one-shot runs.</summary>
[Fact]
public void BuildOptions_disables_probe_for_cli_oneshot_runs()
{
@@ -67,6 +75,7 @@ public sealed class BuildOptionsTests
"CLI commands are one-shot; the background probe loop is unwanted overhead.");
}
/// <summary>Verifies that device is populated from gateway and plc-type options.</summary>
[Fact]
public void BuildOptions_populates_single_device_from_gateway_and_plc_type()
{
@@ -85,6 +94,7 @@ public sealed class BuildOptionsTests
options.Devices[0].DeviceName.ShouldBe("cli-MicroLogix");
}
/// <summary>Verifies that tag list is forwarded verbatim to the options.</summary>
[Fact]
public void BuildOptions_forwards_tag_list_verbatim()
{
@@ -100,6 +110,7 @@ public sealed class BuildOptionsTests
options.Tags.ShouldBe(SampleTags);
}
/// <summary>Verifies that timeout-ms option is propagated to the driver options.</summary>
[Fact]
public void BuildOptions_propagates_timeout_ms()
{
@@ -115,6 +126,7 @@ public sealed class BuildOptionsTests
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
}
/// <summary>Verifies that empty tag list yields an empty tags collection in options.</summary>
[Fact]
public void BuildOptions_with_empty_tag_list_yields_empty_tags_collection()
{
@@ -30,6 +30,7 @@ public sealed class CommandMetadataTests
// ---------- Driver.AbLegacy.Cli-006 — ProbeCommand --type needs short alias 't' ----------
/// <summary>Verifies that ProbeCommand --type has short alias -t.</summary>
[Fact]
public void ProbeCommand_type_has_short_alias_t()
{
@@ -38,6 +39,9 @@ public sealed class CommandMetadataTests
attr.ShortName.ShouldBe('t');
}
/// <summary>Verifies that other commands keep the --type short alias as -t.</summary>
/// <param name="commandType">The command type to inspect for the --type option.</param>
/// <param name="propName">The property name of the --type option on the command.</param>
[Theory]
[InlineData(typeof(ReadCommand), nameof(ReadCommand.DataType))]
[InlineData(typeof(WriteCommand), nameof(WriteCommand.DataType))]
@@ -53,6 +57,7 @@ public sealed class CommandMetadataTests
// ---------- Driver.AbLegacy.Cli-002 — WriteCommand --value help lists full bool alias set ----------
/// <summary>Verifies that WriteCommand --value help lists the full boolean alias set.</summary>
[Fact]
public void WriteCommand_value_help_lists_full_boolean_alias_set()
{
@@ -68,6 +73,7 @@ public sealed class CommandMetadataTests
// ---------- Driver.AbLegacy.Cli-005 — SubscribeCommand --interval-ms help notes 250ms floor ----------
/// <summary>Verifies that SubscribeCommand --interval-ms help notes the PollGroupEngine floor.</summary>
[Fact]
public void SubscribeCommand_interval_ms_help_notes_PollGroupEngine_floor()
{
@@ -11,6 +11,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class WriteCommandParseValueTests
{
/// <summary>Verifies that bit values accept common aliases like "true", "yes", "0", and "OFF".</summary>
/// <param name="raw">The raw string value to parse.</param>
/// <param name="expected">The expected boolean result.</param>
[Theory]
[InlineData("true", true)]
[InlineData("0", false)]
@@ -21,6 +24,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue(raw, AbLegacyDataType.Bit).ShouldBe(expected);
}
/// <summary>Verifies that signed 16-bit integer values are parsed correctly within range.</summary>
[Fact]
public void ParseValue_Int_signed_16bit()
{
@@ -28,6 +32,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("32767", AbLegacyDataType.Int).ShouldBe((short)32767);
}
/// <summary>Verifies that AnalogInt values parse using the same semantics as Int.</summary>
[Fact]
public void ParseValue_AnalogInt_parses_same_as_Int()
{
@@ -35,6 +40,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("100", AbLegacyDataType.AnalogInt).ShouldBeOfType<short>();
}
/// <summary>Verifies that 32-bit integer values are parsed correctly within range.</summary>
[Fact]
public void ParseValue_Long_32bit()
{
@@ -42,18 +48,22 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("2147483647", AbLegacyDataType.Long).ShouldBe(int.MaxValue);
}
/// <summary>Verifies that float values are parsed using invariant culture.</summary>
[Fact]
public void ParseValue_Float_invariant_culture()
{
WriteCommand.ParseValue("3.14", AbLegacyDataType.Float).ShouldBe(3.14f);
}
/// <summary>Verifies that string values are returned unchanged.</summary>
[Fact]
public void ParseValue_String_passthrough()
{
WriteCommand.ParseValue("hello slc", AbLegacyDataType.String).ShouldBe("hello slc");
}
/// <summary>Verifies that Timer, Counter, and Control element types parse as 32-bit integers.</summary>
/// <param name="type">The AB Legacy data type to test.</param>
[Theory]
[InlineData(AbLegacyDataType.TimerElement)]
[InlineData(AbLegacyDataType.CounterElement)]
@@ -64,6 +74,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("42", type).ShouldBeOfType<int>();
}
/// <summary>Verifies that unknown string values are rejected for bit type.</summary>
[Fact]
public void ParseValue_Bit_rejects_unknown_strings()
{
@@ -71,6 +82,7 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("perhaps", AbLegacyDataType.Bit));
}
/// <summary>Verifies that non-numeric values throw CommandException for numeric types.</summary>
[Fact]
public void ParseValue_non_numeric_for_numeric_types_throws_CommandException()
{
@@ -79,6 +91,9 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("xyz", AbLegacyDataType.Int));
}
/// <summary>Verifies that out-of-range values throw CommandException.</summary>
/// <param name="raw">The raw string value that is out of range.</param>
/// <param name="type">The AB Legacy data type to test.</param>
[Theory]
[InlineData("99999", AbLegacyDataType.Int)] // short range is ±32767
[InlineData("-99999", AbLegacyDataType.Int)]
@@ -90,6 +105,10 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue(raw, type));
}
/// <summary>Verifies that PCCC addresses are preserved verbatim in synthesized tag names.</summary>
/// <param name="address">The PCCC address string to test.</param>
/// <param name="type">The AB Legacy data type.</param>
/// <param name="expected">The expected synthesized tag name.</param>
[Theory]
[InlineData("N7:0", AbLegacyDataType.Int, "N7:0:Int")]
[InlineData("B3:0/3", AbLegacyDataType.Bit, "B3:0/3:Bit")]
@@ -12,6 +12,7 @@ public sealed class SnapshotFormatterTests
private static readonly DateTime FixedTime =
new(2026, 4, 21, 12, 34, 56, 789, DateTimeKind.Utc);
/// <summary>Verifies that Format includes tag value, status, and both timestamps.</summary>
[Fact]
public void Format_includes_tag_value_status_and_both_timestamps()
{
@@ -25,6 +26,9 @@ public sealed class SnapshotFormatterTests
output.ShouldContain("Server Time: 2026-04-21T12:34:56.789Z");
}
/// <summary>Verifies that FormatStatus names well-known status codes.</summary>
/// <param name="status">The OPC UA status code to format.</param>
/// <param name="expectedName">The expected name for the status code.</param>
[Theory]
// Numeric codes are the canonical OPC Foundation Opc.Ua.StatusCodes values.
[InlineData(0x00000000u, "Good")]
@@ -49,6 +53,9 @@ public sealed class SnapshotFormatterTests
SnapshotFormatter.FormatStatus(status).ShouldBe($"0x{status:X8} ({expectedName})");
}
/// <summary>Verifies that FormatStatus does not apply pre-fix wrong names.</summary>
/// <param name="status">The OPC UA status code to format.</param>
/// <param name="wrongName">The incorrect name that must not appear in the output.</param>
[Theory]
// Regression for Driver.Cli.Common-001: these codes were previously mapped to the
// wrong names. The hex values below are what the buggy shortlist used; they must
@@ -66,6 +73,7 @@ public sealed class SnapshotFormatterTests
SnapshotFormatter.FormatStatus(status).ShouldNotContain(wrongName);
}
/// <summary>Verifies that FormatStatus unknown codes fall back to severity class.</summary>
[Fact]
public void FormatStatus_unknown_codes_fall_back_to_severity_class()
{
@@ -74,6 +82,7 @@ public sealed class SnapshotFormatterTests
SnapshotFormatter.FormatStatus(0xDEADBEEFu).ShouldBe("0xDEADBEEF (Bad)");
}
/// <summary>Verifies that FormatValue renders null as a placeholder.</summary>
[Fact]
public void FormatValue_renders_null_as_placeholder()
{
@@ -83,6 +92,7 @@ public sealed class SnapshotFormatterTests
output.ShouldContain("Source Time: -"); // null timestamp → dash
}
/// <summary>Verifies that FormatValue formats booleans as lowercase.</summary>
[Fact]
public void FormatValue_formats_booleans_lowercase()
{
@@ -90,6 +100,7 @@ public sealed class SnapshotFormatterTests
SnapshotFormatter.Format("Coil", snap).ShouldContain("Value: true");
}
/// <summary>Verifies that FormatValue formats floats using invariant culture.</summary>
[Fact]
public void FormatValue_formats_floats_invariant_culture()
{
@@ -99,6 +110,7 @@ public sealed class SnapshotFormatterTests
SnapshotFormatter.Format("F8:0", snap).ShouldContain("3.14");
}
/// <summary>Verifies that FormatValue quotes strings.</summary>
[Fact]
public void FormatValue_quotes_strings()
{
@@ -106,6 +118,7 @@ public sealed class SnapshotFormatterTests
SnapshotFormatter.Format("Msg", snap).ShouldContain("\"hello\"");
}
/// <summary>Verifies that FormatWrite shows status with tag name.</summary>
[Fact]
public void FormatWrite_shows_status_with_tag_name()
{
@@ -114,6 +127,7 @@ public sealed class SnapshotFormatterTests
.ShouldBe("Write Scratch: 0x00000000 (Good)");
}
/// <summary>Verifies that FormatTable aligns columns and includes header separator.</summary>
[Fact]
public void FormatTable_aligns_columns_and_includes_header_separator()
{
@@ -134,6 +148,7 @@ public sealed class SnapshotFormatterTests
table.ShouldContain("0x00000000");
}
/// <summary>Verifies that FormatTable rejects mismatched lengths.</summary>
[Fact]
public void FormatTable_rejects_mismatched_lengths()
{
@@ -142,6 +157,7 @@ public sealed class SnapshotFormatterTests
new[] { new DataValueSnapshot(1, 0u, FixedTime, FixedTime) }));
}
/// <summary>Verifies that FormatTimestamp normalises local kind to UTC.</summary>
[Fact]
public void FormatTimestamp_normalises_local_kind_to_utc()
{
@@ -154,6 +170,9 @@ public sealed class SnapshotFormatterTests
// --- Driver.Cli.Common-002: sub-code bits in status codes ---
/// <summary>Verifies that FormatStatus with sub-code bits resolves to named class.</summary>
/// <param name="statusCode">The OPC UA status code with sub-code bits.</param>
/// <param name="expectedName">The expected class name.</param>
[Theory]
// Status codes with non-zero low-word flag bits must still resolve to the named
// high-word class (Driver.Cli.Common-002).
@@ -166,6 +185,9 @@ public sealed class SnapshotFormatterTests
SnapshotFormatter.FormatStatus(statusCode).ShouldContain($"({expectedName})");
}
/// <summary>Verifies that FormatStatus unknown sub-code falls back to severity class.</summary>
/// <param name="statusCode">The OPC UA status code with unknown sub-code.</param>
/// <param name="expectedSeverity">The expected severity class name.</param>
[Theory]
// Unknown sub-codes fall back to the severity class (Good / Uncertain / Bad).
[InlineData(0x80990000u, "Bad")] // Unknown bad sub-code → "Bad"
@@ -179,6 +201,7 @@ public sealed class SnapshotFormatterTests
// --- FormatTable empty-input ---
/// <summary>Verifies that FormatTable with empty input returns header only.</summary>
[Fact]
public void FormatTable_with_empty_input_returns_header_only()
{
@@ -205,15 +228,19 @@ public sealed class DriverCommandBaseTests
[CliFx.Attributes.Command("test-stub", Description = "Test stub — not a real command.")]
private sealed class TestCommand : DriverCommandBase
{
/// <inheritdoc />
public override TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(1);
/// <inheritdoc />
public override ValueTask ExecuteAsync(IConsole console) => default;
// Expose protected methods for testing.
/// <summary>Exposes ConfigureLogging for testing.</summary>
public void InvokeConfigureLogging() => ConfigureLogging();
/// <summary>Exposes FlushLogging for testing.</summary>
public static void InvokeFlushLogging() => FlushLogging();
}
/// <summary>Verifies that ConfigureLogging non-verbose sets warning level.</summary>
[Fact]
public void ConfigureLogging_non_verbose_sets_warning_level()
{
@@ -226,6 +253,7 @@ public sealed class DriverCommandBaseTests
DriverCommandBase_TestCommand_Teardown();
}
/// <summary>Verifies that ConfigureLogging verbose sets debug level.</summary>
[Fact]
public void ConfigureLogging_verbose_sets_debug_level()
{
@@ -236,6 +264,7 @@ public sealed class DriverCommandBaseTests
DriverCommandBase_TestCommand_Teardown();
}
/// <summary>Verifies that ConfigureLogging is idempotent and second call is noop.</summary>
[Fact]
public void ConfigureLogging_is_idempotent_second_call_is_noop()
{
@@ -252,6 +281,7 @@ public sealed class DriverCommandBaseTests
DriverCommandBase_TestCommand_Teardown();
}
/// <summary>Verifies that FlushLogging does not throw.</summary>
[Fact]
public void FlushLogging_does_not_throw()
{
@@ -16,6 +16,8 @@ public sealed class CommandDisposalConventionsTests
{
private static readonly string CommandsDir = LocateCommandsDir();
/// <summary>Verifies that a command does not explicitly call ShutdownAsync (relying on DisposeAsync instead).</summary>
/// <param name="commandFile">The command source file name to inspect.</param>
[Theory]
[InlineData("ProbeCommand.cs")]
[InlineData("ReadCommand.cs")]
@@ -33,6 +35,8 @@ public sealed class CommandDisposalConventionsTests
source.ShouldNotContain("driver.ShutdownAsync(");
}
/// <summary>Verifies that a command uses await using for FocasDriver disposal.</summary>
/// <param name="commandFile">The command source file name to inspect.</param>
[Theory]
[InlineData("ProbeCommand.cs")]
[InlineData("ReadCommand.cs")]
@@ -18,10 +18,15 @@ public sealed class FocasCommandBaseBuildOptionsTests
[Command("noop-test", Description = "Test-only probe of FocasCommandBase.BuildOptions.")]
private sealed class ProbeOnly : FocasCommandBase
{
/// <inheritdoc />
public override ValueTask ExecuteAsync(IConsole console) => default;
/// <summary>Invokes the BuildOptions method with the given tags.</summary>
/// <param name="tags">The list of tag definitions.</param>
/// <returns>The built driver options.</returns>
public FocasDriverOptions Invoke(IReadOnlyList<FocasTagDefinition> tags) => BuildOptions(tags);
}
/// <summary>Verifies that BuildOptions disables probe for one-shot CLI runs.</summary>
[Fact]
public void BuildOptions_disables_probe_for_one_shot_cli_runs()
{
@@ -39,6 +44,7 @@ public sealed class FocasCommandBaseBuildOptionsTests
options.Probe.Enabled.ShouldBeFalse();
}
/// <summary>Verifies that BuildOptions maps TimeoutMs to Timeout TimeSpan.</summary>
[Fact]
public void BuildOptions_maps_TimeoutMs_to_Timeout_TimeSpan()
{
@@ -49,6 +55,7 @@ public sealed class FocasCommandBaseBuildOptionsTests
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
}
/// <summary>Verifies that BuildOptions flows host, port, and series through.</summary>
[Fact]
public void BuildOptions_flows_host_port_series_through()
{
@@ -67,6 +74,7 @@ public sealed class FocasCommandBaseBuildOptionsTests
options.Devices[0].Series.ShouldBe(FocasCncSeries.Zero_i_F);
}
/// <summary>Verifies that BuildOptions forwards tag list verbatim.</summary>
[Fact]
public void BuildOptions_forwards_tag_list_verbatim()
{
@@ -22,14 +22,20 @@ public sealed class FocasCommandBaseValidationTests
[Command("noop-test", Description = "Test-only probe of FocasCommandBase.ValidateOptions.")]
private sealed class Probe : FocasCommandBase
{
/// <summary>Gets or sets the interval in milliseconds.</summary>
public int IntervalMs { get; init; }
/// <inheritdoc />
public override ValueTask ExecuteAsync(IConsole console) => default;
/// <summary>Invokes option validation with interval.</summary>
public void InvokeValidate() => ValidateOptions(IntervalMs);
/// <summary>Invokes option validation without interval.</summary>
public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null);
}
/// <summary>Verifies that default options are accepted.</summary>
[Fact]
public void Validate_accepts_default_options()
{
@@ -37,6 +43,8 @@ public sealed class FocasCommandBaseValidationTests
Should.NotThrow(() => sut.InvokeValidate());
}
/// <summary>Verifies that out-of-range CNC port is rejected.</summary>
/// <param name="port">The port value to test.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
@@ -48,6 +56,8 @@ public sealed class FocasCommandBaseValidationTests
ex.Message.ShouldContain("cnc-port", Case.Insensitive);
}
/// <summary>Verifies that non-positive timeout is rejected.</summary>
/// <param name="timeoutMs">The timeout value in milliseconds to test.</param>
[Theory]
[InlineData(0)]
[InlineData(-100)]
@@ -58,6 +68,8 @@ public sealed class FocasCommandBaseValidationTests
ex.Message.ShouldContain("timeout-ms", Case.Insensitive);
}
/// <summary>Verifies that non-positive interval is rejected.</summary>
/// <param name="intervalMs">The interval value in milliseconds to test.</param>
[Theory]
[InlineData(0)]
[InlineData(-500)]
@@ -68,6 +80,7 @@ public sealed class FocasCommandBaseValidationTests
ex.Message.ShouldContain("interval-ms", Case.Insensitive);
}
/// <summary>Verifies that interval check is skipped when command omits it.</summary>
[Fact]
public void Validate_skips_interval_check_when_command_omits_it()
{
@@ -30,6 +30,7 @@ public sealed class SubscribeCommandConsoleHandlerTests
"Commands", "SubscribeCommand.cs"));
}
/// <summary>Verifies that SubscribeCommand documents why OnDataChange uses synchronous console output.</summary>
[Fact]
public void SubscribeCommand_explains_why_OnDataChange_uses_console_Output_synchronously()
{
@@ -41,6 +42,7 @@ public sealed class SubscribeCommandConsoleHandlerTests
source.ShouldContain("IConsole");
}
/// <summary>Verifies that SubscribeCommand serializes console writes with a lock.</summary>
[Fact]
public void SubscribeCommand_serialises_console_writes_with_a_lock()
{
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class WriteCommandParseValueTests
{
/// <summary>Verifies that ParseValue accepts common boolean aliases for Bit type.</summary>
/// <param name="raw">The raw string input to parse.</param>
/// <param name="expected">The expected boolean result.</param>
[Theory]
[InlineData("true", true)]
[InlineData("0", false)]
@@ -24,6 +27,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue(raw, FocasDataType.Bit).ShouldBe(expected);
}
/// <summary>Verifies that ParseValue rejects garbage Bit input as CommandException.</summary>
[Fact]
public void ParseValue_Bit_rejects_garbage_as_CommandException()
{
@@ -31,6 +35,7 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("maybe", FocasDataType.Bit));
}
/// <summary>Verifies that ParseValue accepts signed byte range.</summary>
[Fact]
public void ParseValue_Byte_signed_range()
{
@@ -39,6 +44,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("127", FocasDataType.Byte).ShouldBe((sbyte)127);
}
/// <summary>Verifies that ParseValue accepts signed 16-bit range.</summary>
[Fact]
public void ParseValue_Int16_signed_range()
{
@@ -46,24 +52,28 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("32767", FocasDataType.Int16).ShouldBe(short.MaxValue);
}
/// <summary>Verifies that ParseValue parses negative Int32 values.</summary>
[Fact]
public void ParseValue_Int32_parses_negative()
{
WriteCommand.ParseValue("-2147483648", FocasDataType.Int32).ShouldBe(int.MinValue);
}
/// <summary>Verifies that ParseValue parses Float32 in invariant culture.</summary>
[Fact]
public void ParseValue_Float32_invariant_culture()
{
WriteCommand.ParseValue("3.14", FocasDataType.Float32).ShouldBe(3.14f);
}
/// <summary>Verifies that ParseValue preserves Float64 higher precision.</summary>
[Fact]
public void ParseValue_Float64_higher_precision()
{
WriteCommand.ParseValue("2.718281828", FocasDataType.Float64).ShouldBeOfType<double>();
}
/// <summary>Verifies that ParseValue passes through string values unchanged.</summary>
[Fact]
public void ParseValue_String_passthrough()
{
@@ -74,6 +84,9 @@ public sealed class WriteCommandParseValueTests
// one-line CliFx error), NOT a raw FormatException stack trace. Previously the raw
// BCL parser exceptions leaked, contradicting how the Bit path already handled bad
// boolean input.
/// <summary>Verifies that ParseValue throws CommandException for non-numeric input to numeric types.</summary>
/// <param name="raw">The non-numeric raw input string.</param>
/// <param name="type">The FOCAS data type to attempt parsing into.</param>
[Theory]
[InlineData("xyz", FocasDataType.Byte)]
[InlineData("xyz", FocasDataType.Int16)]
@@ -88,6 +101,9 @@ public sealed class WriteCommandParseValueTests
}
// OverflowException from out-of-range input must also surface as CommandException.
/// <summary>Verifies that ParseValue throws CommandException for overflow in numeric types.</summary>
/// <param name="raw">The out-of-range raw input string.</param>
/// <param name="type">The FOCAS data type whose range is exceeded.</param>
[Theory]
[InlineData("128", FocasDataType.Byte)] // sbyte max + 1
[InlineData("-129", FocasDataType.Byte)] // sbyte min - 1
@@ -100,6 +116,7 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue(raw, type));
}
/// <summary>Verifies that ParseValue CommandException message names the type and value.</summary>
[Fact]
public void ParseValue_CommandException_message_names_the_type_and_value()
{
@@ -109,6 +126,10 @@ public sealed class WriteCommandParseValueTests
ex.Message.ShouldContain("Int16");
}
/// <summary>Verifies that SynthesiseTagName preserves FOCAS address verbatim.</summary>
/// <param name="address">The FOCAS address string.</param>
/// <param name="type">The FOCAS data type appended to the tag name.</param>
/// <param name="expected">The expected synthesised tag name.</param>
[Theory]
[InlineData("R100", FocasDataType.Int16, "R100:Int16")]
[InlineData("X0.0", FocasDataType.Bit, "X0.0:Bit")]
@@ -18,6 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class CommandCancellationTests
{
/// <summary>Verifies that probe command gracefully handles cancellation during initialization.</summary>
[Fact]
public async Task ProbeCommand_swallows_cancellation_during_initialize()
{
@@ -29,6 +30,7 @@ public sealed class CommandCancellationTests
await Should.NotThrowAsync(async () => await sut.ExecuteAsync(console));
}
/// <summary>Verifies that read command gracefully handles cancellation during initialization.</summary>
[Fact]
public async Task ReadCommand_swallows_cancellation_during_initialize()
{
@@ -46,6 +48,7 @@ public sealed class CommandCancellationTests
await Should.NotThrowAsync(async () => await sut.ExecuteAsync(console));
}
/// <summary>Verifies that write command gracefully handles cancellation during initialization.</summary>
[Fact]
public async Task WriteCommand_swallows_cancellation_during_initialize()
{
@@ -23,11 +23,18 @@ public sealed class ModbusCommandBaseTests
[Command("noop-test", Description = "Test-only probe of ModbusCommandBase.BuildOptions.")]
private sealed class ProbeOnly : ModbusCommandBase
{
/// <inheritdoc />
public override ValueTask ExecuteAsync(IConsole console) => default;
/// <summary>Invokes BuildOptions with the given tags.</summary>
/// <param name="tags">The list of tag definitions to build options for.</param>
public ModbusDriverOptions Invoke(IReadOnlyList<ModbusTagDefinition> tags) => BuildOptions(tags);
/// <summary>Invokes ValidateEndpoint.</summary>
public void InvokeValidate() => ValidateEndpoint();
}
/// <summary>Verifies that BuildOptions disables probe for one-shot CLI runs.</summary>
[Fact]
public void BuildOptions_disables_probe_for_one_shot_cli_runs()
{
@@ -39,6 +46,7 @@ public sealed class ModbusCommandBaseTests
options.Probe.Enabled.ShouldBeFalse();
}
/// <summary>Verifies that BuildOptions maps TimeoutMs to Timeout TimeSpan.</summary>
[Fact]
public void BuildOptions_maps_TimeoutMs_to_Timeout_TimeSpan()
{
@@ -49,6 +57,7 @@ public sealed class ModbusCommandBaseTests
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
}
/// <summary>Verifies that BuildOptions AutoReconnect defaults to true when flag is unset.</summary>
[Fact]
public void BuildOptions_AutoReconnect_defaults_to_true_when_flag_unset()
{
@@ -59,6 +68,7 @@ public sealed class ModbusCommandBaseTests
options.AutoReconnect.ShouldBeTrue();
}
/// <summary>Verifies that BuildOptions AutoReconnect becomes false when disable reconnect flag is set.</summary>
[Fact]
public void BuildOptions_AutoReconnect_becomes_false_when_disable_reconnect_flag_set()
{
@@ -69,6 +79,7 @@ public sealed class ModbusCommandBaseTests
options.AutoReconnect.ShouldBeFalse();
}
/// <summary>Verifies that BuildOptions flows host, port, and unit through correctly.</summary>
[Fact]
public void BuildOptions_flows_host_port_unit_through()
{
@@ -81,6 +92,7 @@ public sealed class ModbusCommandBaseTests
options.UnitId.ShouldBe((byte)17);
}
/// <summary>Verifies that BuildOptions forwards the tag list verbatim.</summary>
[Fact]
public void BuildOptions_forwards_tag_list_verbatim()
{
@@ -96,6 +108,8 @@ public sealed class ModbusCommandBaseTests
// --- Driver.Modbus.Cli-003: parse-time endpoint validation -------------------------------
/// <summary>Verifies that ValidateEndpoint rejects ports outside the range 1 to 65535.</summary>
/// <param name="port">The port value that should be rejected.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
@@ -109,6 +123,8 @@ public sealed class ModbusCommandBaseTests
Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
}
/// <summary>Verifies that ValidateEndpoint accepts ports in the valid range.</summary>
/// <param name="port">The port value that should be accepted.</param>
[Theory]
[InlineData(1)]
[InlineData(502)]
@@ -120,6 +136,8 @@ public sealed class ModbusCommandBaseTests
Should.NotThrow(() => sut.InvokeValidate());
}
/// <summary>Verifies that ValidateEndpoint rejects non-positive timeout values.</summary>
/// <param name="timeoutMs">The timeout value in milliseconds that should be rejected.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
@@ -131,6 +149,8 @@ public sealed class ModbusCommandBaseTests
Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
}
/// <summary>Verifies that ValidateEndpoint rejects unit IDs outside the range 1 to 247.</summary>
/// <param name="unitId">The unit ID value that should be rejected.</param>
[Theory]
[InlineData(0)] // broadcast — disallowed for unicast read/write requests
[InlineData(248)]
@@ -142,6 +162,8 @@ public sealed class ModbusCommandBaseTests
Should.Throw<CliFx.Exceptions.CommandException>(() => sut.InvokeValidate());
}
/// <summary>Verifies that ValidateEndpoint accepts unit IDs in the valid range.</summary>
/// <param name="unitId">The unit ID value that should be accepted.</param>
[Theory]
[InlineData(1)]
[InlineData(247)]
@@ -153,6 +175,7 @@ public sealed class ModbusCommandBaseTests
Should.NotThrow(() => sut.InvokeValidate());
}
/// <summary>Verifies that ValidateEndpoint accepts default options.</summary>
[Fact]
public void ValidateEndpoint_accepts_default_options()
{
@@ -15,6 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class ProbeCommandTests
{
/// <summary>Verifies that ComputeVerdict returns OK when driver state is Healthy and status is Good.</summary>
[Fact]
public void ComputeVerdict_returns_OK_when_state_is_Healthy_and_status_is_Good()
{
@@ -23,6 +24,8 @@ public sealed class ProbeCommandTests
verdict.ShouldContain("OK");
}
/// <summary>Verifies that ComputeVerdict returns FAIL when driver state is not Healthy.</summary>
/// <param name="state">The driver state to test.</param>
[Theory]
[InlineData(DriverState.Faulted)]
[InlineData(DriverState.Reconnecting)]
@@ -34,6 +37,8 @@ public sealed class ProbeCommandTests
verdict.ShouldContain("FAIL");
}
/// <summary>Verifies that ComputeVerdict returns FAIL when snapshot status is Bad even if driver is Healthy.</summary>
/// <param name="statusCode">The OPC UA status code to test.</param>
[Theory]
[InlineData(0x80050000u)] // BadCommunicationError
[InlineData(0x800A0000u)] // BadTimeout
@@ -50,6 +55,7 @@ public sealed class ProbeCommandTests
verdict.ShouldContain("FAIL");
}
/// <summary>Verifies that ComputeVerdict returns DEGRADED for uncertain status with healthy driver.</summary>
[Fact]
public void ComputeVerdict_returns_DEGRADED_for_uncertain_status_with_healthy_driver()
{
@@ -7,6 +7,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class ReadCommandTests
{
/// <summary>Verifies that SynthesiseTagName produces a stable, readable tag name format.</summary>
/// <param name="region">The Modbus region.</param>
/// <param name="address">The register address.</param>
/// <param name="type">The Modbus data type.</param>
/// <param name="expected">The expected synthesised tag name string.</param>
[Theory]
[InlineData(ModbusRegion.HoldingRegisters, 100, ModbusDataType.UInt16, "HR[100]:UInt16")]
[InlineData(ModbusRegion.Coils, 0, ModbusDataType.Bool, "Coil[0]:Bool")]
@@ -13,6 +13,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class WriteCommandParseValueTests
{
/// <summary>Verifies that boolean parsing accepts multiple aliases for true and false.</summary>
/// <param name="raw">The raw string value to parse.</param>
/// <param name="expected">The expected boolean result.</param>
[Theory]
[InlineData("true", true)]
[InlineData("false", false)]
@@ -27,6 +30,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue(raw, ModbusDataType.Bool).ShouldBe(expected);
}
/// <summary>Verifies that boolean parsing rejects unrecognized string values.</summary>
[Fact]
public void ParseValue_Bool_rejects_unknown_strings()
{
@@ -34,6 +38,7 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("maybe", ModbusDataType.Bool));
}
/// <summary>Verifies that Int16 parsing handles positive and negative values correctly.</summary>
[Fact]
public void ParseValue_Int16_parses_positive_and_negative()
{
@@ -41,6 +46,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("32767", ModbusDataType.Int16).ShouldBe((short)32767);
}
/// <summary>Verifies that both UInt16 and Bcd16 parsing return ushort type values.</summary>
[Fact]
public void ParseValue_UInt16_and_Bcd16_both_yield_ushort()
{
@@ -48,12 +54,14 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("65535", ModbusDataType.Bcd16).ShouldBeOfType<ushort>();
}
/// <summary>Verifies that Float32 parsing uses invariant culture with period as decimal separator.</summary>
[Fact]
public void ParseValue_Float32_uses_invariant_culture_period_as_decimal_separator()
{
WriteCommand.ParseValue("3.14", ModbusDataType.Float32).ShouldBe(3.14f);
}
/// <summary>Verifies that Float64 parsing maintains precision for larger decimal values.</summary>
[Fact]
public void ParseValue_Float64_handles_larger_precision()
{
@@ -62,12 +70,14 @@ public sealed class WriteCommandParseValueTests
((double)result).ShouldBe(2.718281828d, 0.0000001d);
}
/// <summary>Verifies that String parsing returns the raw input without modification.</summary>
[Fact]
public void ParseValue_String_returns_raw_string_unmodified()
{
WriteCommand.ParseValue("hello world", ModbusDataType.String).ShouldBe("hello world");
}
/// <summary>Verifies that BitInRegister parsing accepts boolean aliases.</summary>
[Fact]
public void ParseValue_BitInRegister_accepts_bool_aliases()
{
@@ -75,12 +85,14 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("0", ModbusDataType.BitInRegister).ShouldBe(false);
}
/// <summary>Verifies that Int32 parsing handles negative maximum values correctly.</summary>
[Fact]
public void ParseValue_Int32_parses_negative_max()
{
WriteCommand.ParseValue("-2147483648", ModbusDataType.Int32).ShouldBe(int.MinValue);
}
/// <summary>Verifies that parsing rejects non-numeric strings for numeric data types.</summary>
[Fact]
public void ParseValue_rejects_non_numeric_for_numeric_types()
{
@@ -17,6 +17,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class WriteCommandRegionValidationTests
{
/// <summary>Verifies that write to read-only regions is rejected before reaching the driver.</summary>
/// <param name="region">The read-only Modbus region to attempt a write against.</param>
/// <param name="type">The data type used in the write attempt.</param>
/// <param name="value">The raw string value supplied to the write command.</param>
[Theory]
[InlineData(ModbusRegion.DiscreteInputs, ModbusDataType.Bool, "0")]
[InlineData(ModbusRegion.InputRegisters, ModbusDataType.UInt16, "1")]
@@ -38,6 +42,8 @@ public sealed class WriteCommandRegionValidationTests
async () => await sut.ExecuteAsync(console));
}
/// <summary>Verifies that Coils region requires Bool data type (Driver.Modbus.Cli-002).</summary>
/// <param name="type">The non-Bool data type that should be rejected for the Coils region.</param>
[Theory]
[InlineData(ModbusDataType.UInt16)]
[InlineData(ModbusDataType.Int16)]
@@ -16,6 +16,8 @@ public sealed class CommandDisposalConventionsTests
{
private static readonly string CommandsDir = LocateCommandsDir();
/// <summary>Verifies that commands do not call ShutdownAsync explicitly.</summary>
/// <param name="commandFile">The source file name of the command to inspect.</param>
[Theory]
[InlineData("ProbeCommand.cs")]
[InlineData("ReadCommand.cs")]
@@ -33,6 +35,8 @@ public sealed class CommandDisposalConventionsTests
source.ShouldNotContain("driver.ShutdownAsync(");
}
/// <summary>Verifies that commands use await using for S7Driver disposal.</summary>
/// <param name="commandFile">The source file name of the command to inspect.</param>
[Theory]
[InlineData("ProbeCommand.cs")]
[InlineData("ReadCommand.cs")]
@@ -20,13 +20,20 @@ public sealed class S7CommandBaseBuildOptionsTests
// helper. The [Command] attribute is required by the CliFx analyzer
// (CliFx_CommandMustBeAnnotated) — this command is never registered with the CLI app
// but the analyzer rule fires for every ICommand implementor in the compilation.
/// <summary>Test-only S7CommandBase concrete subclass that exposes the protected BuildOptions helper.</summary>
[Command("noop-test", Description = "Test-only probe of S7CommandBase.BuildOptions.")]
private sealed class ProbeOnly : S7CommandBase
{
/// <inheritdoc />
public override ValueTask ExecuteAsync(IConsole console) => default;
/// <summary>Invokes the BuildOptions method with the given tags.</summary>
/// <param name="tags">The tag definitions to pass to BuildOptions.</param>
/// <returns>The resulting S7DriverOptions.</returns>
public S7DriverOptions Invoke(IReadOnlyList<S7TagDefinition> tags) => BuildOptions(tags);
}
/// <summary>Verifies that BuildOptions disables probe for one-shot CLI runs.</summary>
[Fact]
public void BuildOptions_disables_probe_for_one_shot_cli_runs()
{
@@ -46,6 +53,7 @@ public sealed class S7CommandBaseBuildOptionsTests
options.Probe.Enabled.ShouldBeFalse();
}
/// <summary>Verifies that BuildOptions maps TimeoutMs to Timeout TimeSpan.</summary>
[Fact]
public void BuildOptions_maps_TimeoutMs_to_Timeout_TimeSpan()
{
@@ -56,6 +64,7 @@ public sealed class S7CommandBaseBuildOptionsTests
options.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7500));
}
/// <summary>Verifies that BuildOptions flows host, port, cpu, rack, and slot through.</summary>
[Fact]
public void BuildOptions_flows_host_port_cpu_rack_slot_through()
{
@@ -78,6 +87,7 @@ public sealed class S7CommandBaseBuildOptionsTests
options.Slot.ShouldBe((short)2);
}
/// <summary>Verifies that BuildOptions forwards the tag list verbatim.</summary>
[Fact]
public void BuildOptions_forwards_tag_list_verbatim()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class SubscribeCommandConsoleHandlerCommentTests
{
/// <summary>Verifies that SubscribeCommand explains why OnDataChange uses console.Output synchronously.</summary>
[Fact]
public void SubscribeCommand_explains_why_OnDataChange_uses_console_Output_synchronously()
{
@@ -10,6 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class WriteCommandParseValueTests
{
/// <summary>Verifies ParseValue accepts common boolean aliases.</summary>
/// <param name="raw">The raw string value to parse.</param>
/// <param name="expected">The expected boolean result.</param>
[Theory]
[InlineData("true", true)]
[InlineData("0", false)]
@@ -20,6 +23,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue(raw, S7DataType.Bool).ShouldBe(expected);
}
/// <summary>Verifies ParseValue rejects invalid boolean values.</summary>
[Fact]
public void ParseValue_Bool_rejects_garbage()
{
@@ -27,6 +31,7 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("maybe", S7DataType.Bool));
}
/// <summary>Verifies ParseValue handles the full byte range.</summary>
[Fact]
public void ParseValue_Byte_ranges()
{
@@ -34,60 +39,70 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("255", S7DataType.Byte).ShouldBe((byte)255);
}
/// <summary>Verifies ParseValue handles signed 16-bit integers.</summary>
[Fact]
public void ParseValue_Int16_signed_range()
{
WriteCommand.ParseValue("-32768", S7DataType.Int16).ShouldBe((short)-32768);
}
/// <summary>Verifies ParseValue handles unsigned 16-bit integer maximum.</summary>
[Fact]
public void ParseValue_UInt16_unsigned_max()
{
WriteCommand.ParseValue("65535", S7DataType.UInt16).ShouldBe((ushort)65535);
}
/// <summary>Verifies ParseValue parses negative 32-bit integers.</summary>
[Fact]
public void ParseValue_Int32_parses_negative()
{
WriteCommand.ParseValue("-2147483648", S7DataType.Int32).ShouldBe(int.MinValue);
}
/// <summary>Verifies ParseValue parses unsigned 32-bit integer maximum.</summary>
[Fact]
public void ParseValue_UInt32_parses_max()
{
WriteCommand.ParseValue("4294967295", S7DataType.UInt32).ShouldBe(uint.MaxValue);
}
/// <summary>Verifies ParseValue parses signed 64-bit integer minimum.</summary>
[Fact]
public void ParseValue_Int64_parses_min()
{
WriteCommand.ParseValue("-9223372036854775808", S7DataType.Int64).ShouldBe(long.MinValue);
}
/// <summary>Verifies ParseValue parses unsigned 64-bit integer maximum.</summary>
[Fact]
public void ParseValue_UInt64_parses_max()
{
WriteCommand.ParseValue("18446744073709551615", S7DataType.UInt64).ShouldBe(ulong.MaxValue);
}
/// <summary>Verifies ParseValue parses 32-bit floats using invariant culture.</summary>
[Fact]
public void ParseValue_Float32_invariant_culture()
{
WriteCommand.ParseValue("3.14", S7DataType.Float32).ShouldBe(3.14f);
}
/// <summary>Verifies ParseValue parses 64-bit floats with higher precision.</summary>
[Fact]
public void ParseValue_Float64_higher_precision()
{
WriteCommand.ParseValue("2.718281828", S7DataType.Float64).ShouldBeOfType<double>();
}
/// <summary>Verifies ParseValue passes through strings unchanged.</summary>
[Fact]
public void ParseValue_String_passthrough()
{
WriteCommand.ParseValue("hallo siemens", S7DataType.String).ShouldBe("hallo siemens");
}
/// <summary>Verifies ParseValue parses DateTime in roundtrip format.</summary>
[Fact]
public void ParseValue_DateTime_parses_roundtrip_form()
{
@@ -96,6 +111,7 @@ public sealed class WriteCommandParseValueTests
((DateTime)result).Year.ShouldBe(2026);
}
/// <summary>Verifies ParseValue rejects non-numeric input for numeric types.</summary>
[Fact]
public void ParseValue_non_numeric_for_numeric_types_throws()
{
@@ -105,6 +121,7 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("xyz", S7DataType.Int16));
}
/// <summary>Verifies ParseValue throws CommandException on numeric overflow.</summary>
[Fact]
public void ParseValue_overflow_for_numeric_types_throws_CommandException()
{
@@ -113,6 +130,10 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("256", S7DataType.Byte));
}
/// <summary>Verifies SynthesiseTagName preserves S7 address verbatim.</summary>
/// <param name="address">The S7 address string to test.</param>
/// <param name="type">The S7 data type.</param>
/// <param name="expected">The expected synthesized tag name.</param>
[Theory]
[InlineData("DB1.DBW0", S7DataType.Int16, "DB1.DBW0:Int16")]
[InlineData("M0.0", S7DataType.Bool, "M0.0:Bool")]
@@ -22,6 +22,7 @@ public sealed class BrowseCommandFilterTests
SecurityClass: cls,
IsHistorized: false);
/// <summary>Verifies that the collector records variables in call order.</summary>
[Fact]
public void Collector_records_each_variable_in_call_order()
{
@@ -34,6 +35,7 @@ public sealed class BrowseCommandFilterTests
builder.Variables[1].BrowseName.ShouldBe("GVL.B");
}
/// <summary>Verifies that folder returns the same builder for flattening.</summary>
[Fact]
public void Folder_returns_same_builder_so_nested_variables_land_in_one_flat_list()
{
@@ -47,6 +49,7 @@ public sealed class BrowseCommandFilterTests
builder.Variables[0].BrowseName.ShouldBe("GVL.X");
}
/// <summary>Verifies that empty prefix returns all symbols up to max.</summary>
[Fact]
public void FilterAndLimit_empty_prefix_returns_everything_up_to_max()
{
@@ -61,6 +64,7 @@ public sealed class BrowseCommandFilterTests
matched.Count.ShouldBe(3);
}
/// <summary>Verifies that prefix filtering is case-sensitive.</summary>
[Fact]
public void FilterAndLimit_prefix_is_case_sensitive()
{
@@ -77,6 +81,7 @@ public sealed class BrowseCommandFilterTests
matched[0].BrowseName.ShouldBe("GVL_Fixture.x");
}
/// <summary>Verifies that zero max limit means unbounded.</summary>
[Fact]
public void FilterAndLimit_zero_max_means_unbounded()
{
@@ -88,24 +93,29 @@ public sealed class BrowseCommandFilterTests
limit.ShouldBe(10);
}
/// <summary>Verifies that output is capped to max when more symbols match.</summary>
[Fact]
public void FilterAndLimit_caps_to_max_when_more_matched()
{
BrowseCommand.PrintLimit(matchedCount: 1000, max: 50).ShouldBe(50);
}
/// <summary>Verifies that output is not padded when fewer symbols match.</summary>
[Fact]
public void FilterAndLimit_does_not_pad_to_max_when_fewer_matched()
{
BrowseCommand.PrintLimit(matchedCount: 3, max: 50).ShouldBe(3);
}
/// <summary>Verifies that ViewOnly attribute returns RO access tag.</summary>
[Fact]
public void AccessTag_returns_RO_for_ViewOnly_attribute()
{
BrowseCommand.AccessTag(Info(SecurityClassification.ViewOnly)).ShouldBe("RO");
}
/// <summary>Verifies that all classifications except ViewOnly return RW access tag.</summary>
/// <param name="cls">The security classification to test.</param>
[Theory]
[InlineData(SecurityClassification.FreeAccess)]
[InlineData(SecurityClassification.Operate)]
@@ -16,6 +16,8 @@ public sealed class SubscribeCommandMechanismTests
{
private sealed record StubHandle(string DiagnosticId) : ISubscriptionHandle;
/// <summary>Verifies that DescribeMechanism returns ADS notification for native handle.</summary>
/// <param name="diagId">The diagnostic ID of the subscription handle to classify.</param>
[Theory]
[InlineData("twincat-native-sub-1")]
[InlineData("twincat-native-sub-42")]
@@ -25,6 +27,8 @@ public sealed class SubscribeCommandMechanismTests
SubscribeCommand.DescribeMechanism(new StubHandle(diagId)).ShouldBe("ADS notification");
}
/// <summary>Verifies that DescribeMechanism returns polling for anything else.</summary>
/// <param name="diagId">The diagnostic ID of the subscription handle to classify.</param>
[Theory]
[InlineData("pollgroup-1")]
[InlineData("modbus-poll-7")]
@@ -15,6 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class TwinCATCommandBaseTests
{
/// <summary>Verifies that the gateway string uses canonical ADS scheme with port.</summary>
[Fact]
public void Gateway_uses_canonical_ads_scheme_with_port()
{
@@ -27,6 +28,7 @@ public sealed class TwinCATCommandBaseTests
cmd.GatewayForTest.ShouldBe("ads://192.168.1.40.1.1:851");
}
/// <summary>Verifies that the gateway string round-trips through TwinCATAmsAddress.TryParse.</summary>
[Fact]
public void Gateway_round_trips_through_TwinCATAmsAddress_TryParse()
{
@@ -45,6 +47,7 @@ public sealed class TwinCATCommandBaseTests
parsed.Port.ShouldBe(852);
}
/// <summary>Verifies that the driver instance ID includes the AMS target.</summary>
[Fact]
public void DriverInstanceId_includes_ams_target()
{
@@ -57,6 +60,7 @@ public sealed class TwinCATCommandBaseTests
cmd.DriverInstanceIdForTest.ShouldBe("twincat-cli-127.0.0.1.1.1:851");
}
/// <summary>Verifies that timeout is a projection of TimeoutMs and initialization is a no-op.</summary>
[Fact]
public void Timeout_is_projection_of_TimeoutMs_and_init_is_noop()
{
@@ -69,6 +73,7 @@ public sealed class TwinCATCommandBaseTests
cmd.Timeout.ShouldBe(TimeSpan.FromMilliseconds(7777));
}
/// <summary>Verifies that BuildOptions wires device tags, timeout, and disables probe.</summary>
[Fact]
public void BuildOptions_wires_device_tags_timeout_and_disables_probe()
{
@@ -102,6 +107,7 @@ public sealed class TwinCATCommandBaseTests
options.UseNativeNotifications.ShouldBeTrue();
}
/// <summary>Verifies that the PollOnly flag flips UseNativeNotifications off.</summary>
[Fact]
public void BuildOptions_PollOnly_flips_UseNativeNotifications_off()
{
@@ -116,6 +122,7 @@ public sealed class TwinCATCommandBaseTests
// ---- Driver.TwinCAT.Cli-001 (range validation) ----
/// <summary>Verifies that validation rejects zero timeout.</summary>
[Fact]
public void Validate_rejects_zero_timeout()
{
@@ -129,6 +136,7 @@ public sealed class TwinCATCommandBaseTests
ex.Message.ShouldContain("--timeout-ms");
}
/// <summary>Verifies that validation rejects negative timeout.</summary>
[Fact]
public void Validate_rejects_negative_timeout()
{
@@ -141,6 +149,8 @@ public sealed class TwinCATCommandBaseTests
Should.Throw<CliFx.Exceptions.CommandException>(() => cmd.ValidateForTest());
}
/// <summary>Verifies that validation rejects out-of-range AMS port values.</summary>
/// <param name="port">The out-of-range AMS port value to test.</param>
[Theory]
[InlineData(0)]
[InlineData(-1)]
@@ -158,6 +168,8 @@ public sealed class TwinCATCommandBaseTests
ex.Message.ShouldContain("--ams-port");
}
/// <summary>Verifies that validation accepts in-range AMS port values.</summary>
/// <param name="port">The valid AMS port value to test.</param>
[Theory]
[InlineData(1)]
[InlineData(801)]
@@ -174,6 +186,7 @@ public sealed class TwinCATCommandBaseTests
Should.NotThrow(() => cmd.ValidateForTest());
}
/// <summary>Verifies that SubscribeCommand validation rejects zero interval.</summary>
[Fact]
public void SubscribeCommand_validate_rejects_zero_interval()
{
@@ -187,6 +200,7 @@ public sealed class TwinCATCommandBaseTests
ex.Message.ShouldContain("--interval-ms");
}
/// <summary>Verifies that SubscribeCommand validation rejects negative interval.</summary>
[Fact]
public void SubscribeCommand_validate_rejects_negative_interval()
{
@@ -201,6 +215,7 @@ public sealed class TwinCATCommandBaseTests
// ---- Driver.TwinCAT.Cli-004 (PollOnly off BrowseCommand surface) ----
/// <summary>Verifies that BrowseCommand does not expose the poll-only flag.</summary>
[Fact]
public void BrowseCommand_does_not_expose_poll_only_flag()
{
@@ -212,6 +227,7 @@ public sealed class TwinCATCommandBaseTests
props.ShouldNotContain(p => p.Name == "PollOnly");
}
/// <summary>Verifies that ProbeCommand still exposes the poll-only flag.</summary>
[Fact]
public void ProbeCommand_still_exposes_poll_only_flag()
{
@@ -224,6 +240,7 @@ public sealed class TwinCATCommandBaseTests
// ---- Driver.TwinCAT.Cli-005 (probe --type short alias) ----
/// <summary>Verifies that ProbeCommand type option carries the short alias 't'.</summary>
[Fact]
public void ProbeCommand_type_option_carries_short_alias_t()
{
@@ -12,6 +12,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests;
[Trait("Category", "Unit")]
public sealed class WriteCommandParseValueTests
{
/// <summary>Verifies that ParseValue Bool accepts common aliases.</summary>
/// <param name="raw">The raw input string to parse.</param>
/// <param name="expected">The expected boolean result.</param>
[Theory]
[InlineData("true", true)]
[InlineData("0", false)]
@@ -22,6 +25,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue(raw, TwinCATDataType.Bool).ShouldBe(expected);
}
/// <summary>Verifies that ParseValue Bool rejects garbage.</summary>
[Fact]
public void ParseValue_Bool_rejects_garbage()
{
@@ -29,72 +33,84 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("maybe", TwinCATDataType.Bool));
}
/// <summary>Verifies that ParseValue SInt parses signed byte.</summary>
[Fact]
public void ParseValue_SInt_signed_byte()
{
WriteCommand.ParseValue("-128", TwinCATDataType.SInt).ShouldBe((sbyte)-128);
}
/// <summary>Verifies that ParseValue USInt parses unsigned byte.</summary>
[Fact]
public void ParseValue_USInt_unsigned_byte()
{
WriteCommand.ParseValue("255", TwinCATDataType.USInt).ShouldBe((byte)255);
}
/// <summary>Verifies that ParseValue Int parses signed 16-bit value.</summary>
[Fact]
public void ParseValue_Int_signed_16bit()
{
WriteCommand.ParseValue("-32768", TwinCATDataType.Int).ShouldBe((short)-32768);
}
/// <summary>Verifies that ParseValue UInt parses unsigned 16-bit value.</summary>
[Fact]
public void ParseValue_UInt_unsigned_16bit()
{
WriteCommand.ParseValue("65535", TwinCATDataType.UInt).ShouldBe((ushort)65535);
}
/// <summary>Verifies that ParseValue DInt parses int32 bounds.</summary>
[Fact]
public void ParseValue_DInt_int32_bounds()
{
WriteCommand.ParseValue("-2147483648", TwinCATDataType.DInt).ShouldBe(int.MinValue);
}
/// <summary>Verifies that ParseValue UDInt parses uint32 maximum.</summary>
[Fact]
public void ParseValue_UDInt_uint32_max()
{
WriteCommand.ParseValue("4294967295", TwinCATDataType.UDInt).ShouldBe(uint.MaxValue);
}
/// <summary>Verifies that ParseValue LInt parses int64 minimum.</summary>
[Fact]
public void ParseValue_LInt_int64_min()
{
WriteCommand.ParseValue("-9223372036854775808", TwinCATDataType.LInt).ShouldBe(long.MinValue);
}
/// <summary>Verifies that ParseValue ULInt parses uint64 maximum.</summary>
[Fact]
public void ParseValue_ULInt_uint64_max()
{
WriteCommand.ParseValue("18446744073709551615", TwinCATDataType.ULInt).ShouldBe(ulong.MaxValue);
}
/// <summary>Verifies that ParseValue Real uses invariant culture.</summary>
[Fact]
public void ParseValue_Real_invariant_culture()
{
WriteCommand.ParseValue("3.14", TwinCATDataType.Real).ShouldBe(3.14f);
}
/// <summary>Verifies that ParseValue LReal has higher precision.</summary>
[Fact]
public void ParseValue_LReal_higher_precision()
{
WriteCommand.ParseValue("2.718281828", TwinCATDataType.LReal).ShouldBeOfType<double>();
}
/// <summary>Verifies that ParseValue String passes through input unchanged.</summary>
[Fact]
public void ParseValue_String_passthrough()
{
WriteCommand.ParseValue("hallo beckhoff", TwinCATDataType.String).ShouldBe("hallo beckhoff");
}
/// <summary>Verifies that ParseValue WString passes through input unchanged.</summary>
[Fact]
public void ParseValue_WString_passthrough()
{
@@ -103,6 +119,8 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("überstall", TwinCATDataType.WString).ShouldBe("überstall");
}
/// <summary>Verifies that ParseValue IEC date/time variants land on uint32.</summary>
/// <param name="type">The IEC 61131-3 data type to parse.</param>
[Theory]
[InlineData(TwinCATDataType.Time)]
[InlineData(TwinCATDataType.Date)]
@@ -115,6 +133,7 @@ public sealed class WriteCommandParseValueTests
WriteCommand.ParseValue("1234567", type).ShouldBeOfType<uint>();
}
/// <summary>Verifies that ParseValue Structure is refused.</summary>
[Fact]
public void ParseValue_Structure_refused()
{
@@ -122,6 +141,7 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("42", TwinCATDataType.Structure));
}
/// <summary>Verifies that ParseValue non-numeric for numeric types throws.</summary>
[Fact]
public void ParseValue_non_numeric_for_numeric_types_throws()
{
@@ -129,6 +149,10 @@ public sealed class WriteCommandParseValueTests
() => WriteCommand.ParseValue("xyz", TwinCATDataType.DInt));
}
/// <summary>Verifies that SynthesiseTagName preserves symbolic path verbatim.</summary>
/// <param name="symbol">The symbolic path to synthesise.</param>
/// <param name="type">The TwinCAT data type.</param>
/// <param name="expected">The expected synthesised tag name.</param>
[Theory]
[InlineData("MAIN.bStart", TwinCATDataType.Bool, "MAIN.bStart:Bool")]
[InlineData("GVL.Counter", TwinCATDataType.DInt, "GVL.Counter:DInt")]
@@ -15,9 +15,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
[Trait("Requires", "AbServer")]
public sealed class AbCipReadSmokeTests
{
/// <summary>Gets the test profiles for the theory tests.</summary>
public static IEnumerable<object[]> Profiles =>
KnownProfiles.All.Select(p => new object[] { p });
/// <summary>Verifies that the driver can read a seeded DInt value from an AB server.</summary>
/// <param name="profile">The AB server profile to test against.</param>
[AbServerTheory]
[MemberData(nameof(Profiles))]
public async Task Driver_reads_seeded_DInt_from_ab_server(AbServerProfile profile)
@@ -31,11 +31,16 @@ public sealed class AbServerFixture : IAsyncLifetime
// 10.100.0.35 = the shared Docker host (see CLAUDE.md "Docker Workflow"). Migrated
// off this VM's 127.0.0.1 on 2026-04-28 alongside the rest of the Docker-host move.
// Override via AB_SERVER_ENDPOINT to point at a real PLC or a locally-running container.
/// <summary>Gets the host address of the AB server.</summary>
public string Host { get; } = "10.100.0.35";
/// <summary>Gets the port of the AB server.</summary>
public int Port { get; } = AbServerProfile.DefaultPort;
/// <summary>Initializes a new instance of the AbServerFixture class with the default profile.</summary>
public AbServerFixture() : this(KnownProfiles.ControlLogix) { }
/// <summary>Initializes a new instance of the AbServerFixture class with the specified profile.</summary>
/// <param name="profile">The AB server profile to use.</param>
public AbServerFixture(AbServerProfile profile)
{
Profile = profile ?? throw new ArgumentNullException(nameof(profile));
@@ -50,7 +55,9 @@ public sealed class AbServerFixture : IAsyncLifetime
}
}
/// <inheritdoc />
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
/// <inheritdoc />
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
@@ -92,6 +99,7 @@ public sealed class AbServerFixture : IAsyncLifetime
/// </summary>
public sealed class AbServerFactAttribute : FactAttribute
{
/// <summary>Initializes a new instance of the AbServerFactAttribute class.</summary>
public AbServerFactAttribute()
{
if (!AbServerFixture.IsServerAvailable())
@@ -109,6 +117,7 @@ public sealed class AbServerFactAttribute : FactAttribute
/// </summary>
public sealed class AbServerTheoryAttribute : TheoryAttribute
{
/// <summary>Initializes a new instance of the AbServerTheoryAttribute class.</summary>
public AbServerTheoryAttribute()
{
if (!AbServerFixture.IsServerAvailable())
@@ -26,29 +26,37 @@ public sealed record AbServerProfile(
/// <summary>Canonical profiles covering every AB CIP family shipped in PRs 912.</summary>
public static class KnownProfiles
{
/// <summary>Gets all known server profiles.</summary>
public static IReadOnlyList<AbServerProfile> All { get; } =
[ControlLogix, CompactLogix, Micro800, GuardLogix];
/// <summary>Gets the ControlLogix profile.</summary>
public static readonly AbServerProfile ControlLogix = new(
Family: AbCipPlcFamily.ControlLogix,
ComposeProfile: "controllogix",
Notes: "Widest-coverage profile — PR 9 baseline. UDTs unit-tested via golden Template Object buffers; ab_server lacks full UDT emulation.");
/// <summary>Gets the CompactLogix profile.</summary>
public static readonly AbServerProfile CompactLogix = new(
Family: AbCipPlcFamily.CompactLogix,
ComposeProfile: "compactlogix",
Notes: "ab_server doesn't enforce the narrower ConnectionSize; driver-side profile caps it per PR 10.");
/// <summary>Gets the Micro800 profile.</summary>
public static readonly AbServerProfile Micro800 = new(
Family: AbCipPlcFamily.Micro800,
ComposeProfile: "micro800",
Notes: "--plc=Micro800 mode (unconnected-only, empty path). Driver-side enforcement verified in the unit suite.");
/// <summary>Gets the GuardLogix profile.</summary>
public static readonly AbServerProfile GuardLogix = new(
Family: AbCipPlcFamily.GuardLogix,
ComposeProfile: "guardlogix",
Notes: "ab_server has no safety subsystem — _S-suffixed seed tag triggers driver-side ViewOnly classification only.");
public static IReadOnlyList<AbServerProfile> All { get; } =
[ControlLogix, CompactLogix, Micro800, GuardLogix];
/// <summary>Gets the profile for a specific family.</summary>
/// <param name="family">The AB CIP PLC family.</param>
/// <returns>The corresponding server profile.</returns>
public static AbServerProfile ForFamily(AbCipPlcFamily family) =>
All.FirstOrDefault(p => p.Family == family)
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
@@ -39,6 +39,7 @@ public static class AbServerProfileGate
/// Skip the calling test via <c>Assert.Skip</c> when <see cref="CurrentProfile"/>
/// isn't in <paramref name="requiredProfiles"/>. Case-insensitive match.
/// </summary>
/// <param name="requiredProfiles">The list of profile names to check against the current profile.</param>
public static void SkipUnless(params string[] requiredProfiles)
{
foreach (var p in requiredProfiles)
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests;
[Trait("Category", "Unit")]
public sealed class AbServerProfileTests
{
/// <summary>Verifies known profiles for family returns expected compose profile.</summary>
/// <param name="family">The AB CIP PLC family.</param>
/// <param name="expected">The expected Docker Compose profile name.</param>
[Theory]
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
@@ -24,6 +27,7 @@ public sealed class AbServerProfileTests
KnownProfiles.ForFamily(family).ComposeProfile.ShouldBe(expected);
}
/// <summary>Verifies known profiles all covers every family.</summary>
[Fact]
public void KnownProfiles_All_Covers_Every_Family()
{
@@ -32,6 +36,7 @@ public sealed class AbServerProfileTests
covered.ShouldContain(family, $"Family {family} is missing a KnownProfiles entry.");
}
/// <summary>Verifies default port matches EtherNetIP standard.</summary>
[Fact]
public void DefaultPort_Matches_EtherNetIP_Standard()
{
@@ -39,6 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
[Trait("Tier", "Emulate")]
public sealed class AbCipEmulateAlmdTests
{
/// <summary>Verifies that real ALMD raise fires OnAlarmEvent through the driver projection.</summary>
[AbServerFact]
public async Task Real_ALMD_raise_fires_OnAlarmEvent_through_the_driver_projection()
{
@@ -37,6 +37,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
[Trait("Tier", "Emulate")]
public sealed class AbCipEmulateUdtReadTests
{
/// <summary>Verifies that reading a whole UDT decodes each member at its template object offset.</summary>
[AbServerFact]
public async Task WholeUdt_read_decodes_each_member_at_its_Template_Object_offset()
{
@@ -22,6 +22,7 @@ public sealed class AbCipAlarmProjectionTests
new AbCipStructureMember("In", AbCipDataType.DInt),
]);
/// <summary>Verifies that ALMD structure signature is correctly detected as an alarm.</summary>
[Fact]
public void AbCipAlarmDetector_Flags_AlmdSignature_As_Alarm()
{
@@ -36,6 +37,7 @@ public sealed class AbCipAlarmProjectionTests
AbCipAlarmDetector.IsAlmd(atomic).ShouldBeFalse();
}
/// <summary>Verifies that severity values map correctly to OPC UA alarm severity levels.</summary>
[Fact]
public void Severity_Mapping_Matches_OPC_UA_Convention()
{
@@ -46,6 +48,7 @@ public sealed class AbCipAlarmProjectionTests
AbCipAlarmProjection.MapSeverity(900).ShouldBe(AlarmSeverity.Critical);
}
/// <summary>Verifies that disabled alarm projection returns a valid handle but does not poll.</summary>
[Fact]
public async Task FeatureFlag_Off_SubscribeAlarms_Returns_Handle_But_Never_Polls()
{
@@ -72,6 +75,7 @@ public sealed class AbCipAlarmProjectionTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that enabled alarm projection starts polling and fires raise event on 0-to-1 transition.</summary>
[Fact]
public async Task FeatureFlag_On_Subscribe_Starts_Polling_And_Fires_Raise_On_0_to_1()
{
@@ -115,6 +119,7 @@ public sealed class AbCipAlarmProjectionTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that alarm clear event fires on 1-to-0 transition.</summary>
[Fact]
public async Task Clear_Event_Fires_On_1_to_0_Transition()
{
@@ -155,6 +160,7 @@ public sealed class AbCipAlarmProjectionTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that unsubscribing stops the alarm poll loop.</summary>
[Fact]
public async Task Unsubscribe_Stops_The_Poll_Loop()
{
@@ -18,6 +18,7 @@ public sealed class AbCipBoolInDIntRmwTests
// Uses the base FakeAbCipTag's Value + ReadCount + WriteCount.
}
/// <summary>Verifies that bit set reads parent, ORs bit, and writes back.</summary>
[Fact]
public async Task Bit_set_reads_parent_ORs_bit_writes_back()
{
@@ -48,6 +49,7 @@ public sealed class AbCipBoolInDIntRmwTests
factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1);
}
/// <summary>Verifies that bit clear preserves other bits.</summary>
[Fact]
public async Task Bit_clear_preserves_other_bits()
{
@@ -70,6 +72,7 @@ public sealed class AbCipBoolInDIntRmwTests
(updated & ~(1 << 3)).ShouldBe(unchecked((int)0xFFFFFFF7)); // every other bit preserved
}
/// <summary>Verifies that concurrent bit writes to same parent compose correctly.</summary>
[Fact]
public async Task Concurrent_bit_writes_to_same_parent_compose_correctly()
{
@@ -94,6 +97,7 @@ public sealed class AbCipBoolInDIntRmwTests
Convert.ToInt32(factory.Tags["Flags"].Value).ShouldBe(0xFF);
}
/// <summary>Verifies that bit writes to different parents each get their own runtime.</summary>
[Fact]
public async Task Bit_writes_to_different_parents_each_get_own_runtime()
{
@@ -120,6 +124,7 @@ public sealed class AbCipBoolInDIntRmwTests
factory.Tags.ShouldContainKey("Motor2.Flags");
}
/// <summary>Verifies that repeat bit writes reuse one parent runtime.</summary>
[Fact]
public async Task Repeat_bit_writes_reuse_one_parent_runtime()
{
@@ -18,6 +18,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-001 — ReinitializeAsync must apply a changed config JSON ----
/// <summary>Tests that InitializeAsync applies devices and tags from the config JSON.</summary>
[Fact]
public async Task InitializeAsync_applies_devices_and_tags_from_the_config_json()
{
@@ -38,6 +39,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Tests that ReinitializeAsync with changed config JSON picks up the new device.</summary>
[Fact]
public async Task ReinitializeAsync_with_a_changed_config_json_picks_up_the_new_device()
{
@@ -60,6 +62,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
drv.GetDeviceState(Device).ShouldBeNull();
}
/// <summary>Tests that InitializeAsync with blank JSON keeps construction-time options.</summary>
[Fact]
public async Task InitializeAsync_with_blank_json_keeps_construction_time_options()
{
@@ -78,6 +81,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-003 — declaration-only whole-UDT grouping is opt-in ----
/// <summary>Tests that whole UDT grouping is off by default so members read per tag.</summary>
[Fact]
public async Task Whole_udt_grouping_is_off_by_default_so_members_read_per_tag()
{
@@ -107,6 +111,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
factory.Tags.ShouldNotContainKey("Motor");
}
/// <summary>Tests that planner forms no groups when declaration-only grouping is disabled.</summary>
[Fact]
public void Planner_forms_no_groups_when_declaration_only_grouping_is_disabled()
{
@@ -131,6 +136,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-008 — ShutdownAsync awaits probe loops; reads are concurrency-safe ----
/// <summary>Tests that ShutdownAsync awaits the probe loop before returning.</summary>
[Fact]
public async Task ShutdownAsync_awaits_the_probe_loop_before_returning()
{
@@ -155,6 +161,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
drv.DeviceCount.ShouldBe(0);
}
/// <summary>Tests that ShutdownAsync is idempotent.</summary>
[Fact]
public async Task ShutdownAsync_is_idempotent()
{
@@ -168,6 +175,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
await Should.NotThrowAsync(() => drv.ShutdownAsync(CancellationToken.None));
}
/// <summary>Tests that concurrent first reads of the same tag do not corrupt the runtime cache.</summary>
[Fact]
public async Task Concurrent_first_reads_of_the_same_tag_do_not_corrupt_the_runtime_cache()
{
@@ -193,6 +201,9 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-004 — LInt/ULInt/UDInt declared type must agree with runtime value type ----
/// <summary>Verifies that AbCipDataType maps large integer types to their correct driver types.</summary>
/// <param name="abType">The AB CIP data type.</param>
/// <param name="expected">The expected driver data type.</param>
[Theory]
[InlineData(AbCipDataType.LInt, DriverDataType.Int64)]
[InlineData(AbCipDataType.ULInt, DriverDataType.UInt64)]
@@ -205,6 +216,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
abType.ToDriverDataType().ShouldBe(expected);
}
/// <summary>Tests that read UDInt tag returns uint value not negative-wrapped int.</summary>
[Fact]
public async Task Read_UDInt_tag_returns_uint_value_not_negative_wrapped_int()
{
@@ -230,6 +242,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-005 — Structure parent not registered; duplicate key check ----
/// <summary>Tests that structure parent tag read returns BadNotSupported not Good null.</summary>
[Fact]
public async Task Structure_parent_tag_read_returns_BadNotSupported_not_Good_null()
{
@@ -259,6 +272,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
results.Single().Value.ShouldBeNull();
}
/// <summary>Tests that InitializeAsync throws on duplicate tag name.</summary>
[Fact]
public void InitializeAsync_throws_on_duplicate_tag_name()
{
@@ -278,6 +292,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult());
}
/// <summary>Tests that InitializeAsync throws when member name collides with independent tag.</summary>
[Fact]
public void InitializeAsync_throws_when_member_name_collides_with_independent_tag()
{
@@ -302,6 +317,7 @@ public sealed class AbCipDriverCodeReviewRegressionTests
// ---- Driver.AbCip-010 — stale runtime evicted on failure ----
/// <summary>Tests that read failure evicts runtime so next read creates fresh handle.</summary>
[Fact]
public async Task Read_failure_evicts_runtime_so_next_read_creates_fresh_handle()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverDiscoveryTests
{
/// <summary>Verifies that pre-declared tags emit as variables under device folder.</summary>
[Fact]
public async Task PreDeclared_tags_emit_as_variables_under_device_folder()
{
@@ -33,6 +34,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Verifies that device folder display name falls back to host address when not provided.</summary>
[Fact]
public async Task Device_folder_displayname_falls_back_to_host_address()
{
@@ -49,6 +51,7 @@ public sealed class AbCipDriverDiscoveryTests
&& f.DisplayName == "ab://10.0.0.5/1,0");
}
/// <summary>Verifies that pre-declared system tags are filtered out.</summary>
[Fact]
public async Task PreDeclared_system_tags_are_filtered_out()
{
@@ -70,6 +73,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
}
/// <summary>Verifies that tags for mismatched devices are ignored.</summary>
[Fact]
public async Task Tags_for_mismatched_device_are_ignored()
{
@@ -86,6 +90,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.ShouldBeEmpty();
}
/// <summary>Verifies that controller enumeration adds tags under Discovered folder.</summary>
[Fact]
public async Task Controller_enumeration_adds_tags_under_Discovered_folder()
{
@@ -108,6 +113,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex");
}
/// <summary>Verifies that controller enumeration honours system tag hint and filter.</summary>
[Fact]
public async Task Controller_enumeration_honours_system_tag_hint_and_filter()
{
@@ -129,6 +135,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
}
/// <summary>Verifies that controller enumeration ReadOnly flag surfaces ViewOnly classification.</summary>
[Fact]
public async Task Controller_enumeration_ReadOnly_surfaces_ViewOnly_classification()
{
@@ -148,6 +155,7 @@ public sealed class AbCipDriverDiscoveryTests
builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Verifies that controller enumeration receives correct device parameters.</summary>
[Fact]
public async Task Controller_enumeration_receives_correct_device_params()
{
@@ -171,6 +179,7 @@ public sealed class AbCipDriverDiscoveryTests
capturedParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
}
/// <summary>Verifies that default enumerator factory is used when not injected.</summary>
[Fact]
public void Default_enumerator_factory_is_used_when_not_injected()
{
@@ -183,6 +192,9 @@ public sealed class AbCipDriverDiscoveryTests
drv.ShouldNotBeNull();
}
/// <summary>Verifies that system tag filter rejects infrastructure names.</summary>
/// <param name="name">The tag name to test.</param>
/// <param name="expected">The expected result of the filter.</param>
[Theory]
[InlineData("__DEFVAL_X", true)]
[InlineData("__DEFAULT_Y", true)]
@@ -203,6 +215,7 @@ public sealed class AbCipDriverDiscoveryTests
AbCipSystemTagFilter.IsSystemTag(name).ShouldBe(expected);
}
/// <summary>Verifies that template cache roundtrip put and get work correctly.</summary>
[Fact]
public void TemplateCache_roundtrip_put_get()
{
@@ -222,6 +235,7 @@ public sealed class AbCipDriverDiscoveryTests
cache.Count.ShouldBe(0);
}
/// <summary>Verifies that FlushOptionalCachesAsync clears the template cache.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_clears_template_cache()
{
@@ -235,39 +249,69 @@ public sealed class AbCipDriverDiscoveryTests
// ---- helpers ----
/// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of recorded folders.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the list of recorded variables.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Records a folder node.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Records a variable node.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="info">The attribute information for the variable.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>Adds a property (no-op in test).</summary>
/// <param name="_">Property name (unused in test).</param>
/// <param name="__">Property data type (unused in test).</param>
/// <param name="___">Property value (unused in test).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
/// <summary>Test variable handle.</summary>
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference of the variable.</summary>
public string FullReference => fullRef;
/// <summary>Marks the variable as an alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
/// <summary>Null sink for alarm conditions.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <summary>Handles alarm transition (no-op).</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
/// <summary>Fake enumerator factory for testing.</summary>
private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory
{
private readonly AbCipDiscoveredTag[] _tags;
/// <summary>Gets the last captured device parameters.</summary>
public AbCipTagCreateParams? LastDeviceParams { get; private set; }
/// <summary>Initializes a new instance of the FakeEnumeratorFactory.</summary>
/// <param name="tags">The tags to enumerate.</param>
public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
/// <summary>Creates a new fake enumerator.</summary>
public IAbCipTagEnumerator Create() => new FakeEnumerator(this);
/// <summary>Fake tag enumerator for testing.</summary>
private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator
{
/// <summary>Enumerates discovered tags asynchronously.</summary>
/// <param name="deviceParams">The device parameters for enumeration.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
AbCipTagCreateParams deviceParams,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -276,6 +320,7 @@ public sealed class AbCipDriverDiscoveryTests
await Task.CompletedTask;
foreach (var t in outer._tags) yield return t;
}
/// <summary>Disposes the enumerator.</summary>
public void Dispose() { }
}
}
@@ -21,6 +21,7 @@ public sealed class AbCipDriverReadTests
return (drv, factory);
}
/// <summary>Verifies that an unknown reference maps to BadNodeIdUnknown status.</summary>
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
@@ -33,6 +34,7 @@ public sealed class AbCipDriverReadTests
snapshots.Single().Value.ShouldBeNull();
}
/// <summary>Verifies that a tag on an unknown device maps to BadNodeIdUnknown status.</summary>
[Fact]
public async Task Tag_on_unknown_device_maps_to_BadNodeIdUnknown()
{
@@ -49,6 +51,7 @@ public sealed class AbCipDriverReadTests
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that a successful DInt read returns Good status with the correct value.</summary>
[Fact]
public async Task Successful_DInt_read_returns_Good_with_value()
{
@@ -67,6 +70,7 @@ public sealed class AbCipDriverReadTests
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(1);
}
/// <summary>Verifies that repeated reads reuse the runtime without reinitializing.</summary>
[Fact]
public async Task Repeat_read_reuses_runtime_without_reinitialise()
{
@@ -83,6 +87,7 @@ public sealed class AbCipDriverReadTests
factory.Tags["Motor1.Speed"].ReadCount.ShouldBe(3);
}
/// <summary>Verifies that non-zero libplctag status is mapped via AbCipStatusMapper.</summary>
[Fact]
public async Task NonZero_libplctag_status_maps_via_AbCipStatusMapper()
{
@@ -97,6 +102,7 @@ public sealed class AbCipDriverReadTests
snapshots.Single().Value.ShouldBeNull();
}
/// <summary>Verifies that an exception during read surfaces BadCommunicationError status.</summary>
[Fact]
public async Task Exception_during_read_surfaces_BadCommunicationError()
{
@@ -112,6 +118,7 @@ public sealed class AbCipDriverReadTests
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>Verifies that batched reads preserve order and per-tag status.</summary>
[Fact]
public async Task Batched_reads_preserve_order_and_per_tag_status()
{
@@ -136,6 +143,7 @@ public sealed class AbCipDriverReadTests
snapshots.ShouldAllBe(s => s.StatusCode == AbCipStatusMapper.Good);
}
/// <summary>Verifies that a successful read marks health as Healthy.</summary>
[Fact]
public async Task Successful_read_marks_health_Healthy()
{
@@ -149,6 +157,7 @@ public sealed class AbCipDriverReadTests
drv.GetHealth().LastSuccessfulRead.ShouldNotBeNull();
}
/// <summary>Verifies that tag creation parameters are built correctly from device and profile.</summary>
[Fact]
public async Task TagCreateParams_are_built_from_device_and_profile()
{
@@ -166,6 +175,7 @@ public sealed class AbCipDriverReadTests
p.TagName.ShouldBe("Program:P.Counter");
}
/// <summary>Verifies that cancellation propagates from read operations.</summary>
[Fact]
public async Task Cancellation_propagates_from_read()
{
@@ -183,6 +193,7 @@ public sealed class AbCipDriverReadTests
() => drv.ReadAsync(["Slow"], cts.Token));
}
/// <summary>Verifies that ShutdownAsync disposes each tag runtime.</summary>
[Fact]
public async Task ShutdownAsync_disposes_each_tag_runtime()
{
@@ -199,6 +210,7 @@ public sealed class AbCipDriverReadTests
factory.Tags["B"].Disposed.ShouldBeTrue();
}
/// <summary>Verifies that initialization failure disposes the tag and surfaces communication error.</summary>
[Fact]
public async Task Initialize_failure_disposes_tag_and_surfaces_communication_error()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipDriverTests
{
/// <summary>Verifies AbCipDriver reports correct driver type and instance ID.</summary>
[Fact]
public void DriverType_is_AbCip()
{
@@ -17,6 +18,7 @@ public sealed class AbCipDriverTests
drv.DriverInstanceId.ShouldBe("drv-1");
}
/// <summary>Verifies InitializeAsync with no devices succeeds and marks driver healthy.</summary>
[Fact]
public async Task InitializeAsync_with_empty_devices_succeeds_and_marks_healthy()
{
@@ -25,6 +27,7 @@ public sealed class AbCipDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies InitializeAsync registers devices with their respective PLC family profiles.</summary>
[Fact]
public async Task InitializeAsync_registers_each_device_with_its_family_profile()
{
@@ -44,6 +47,7 @@ public sealed class AbCipDriverTests
drv.GetDeviceState("ab://10.0.0.6/")!.Profile.ShouldBe(AbCipPlcFamilyProfile.Micro800);
}
/// <summary>Verifies InitializeAsync rejects malformed host addresses and faults the driver.</summary>
[Fact]
public async Task InitializeAsync_with_malformed_host_address_faults()
{
@@ -57,6 +61,7 @@ public sealed class AbCipDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>Verifies ShutdownAsync clears devices and marks driver state unknown.</summary>
[Fact]
public async Task ShutdownAsync_clears_devices_and_marks_unknown()
{
@@ -73,6 +78,7 @@ public sealed class AbCipDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Verifies ReinitializeAsync stops and restarts all devices.</summary>
[Fact]
public async Task ReinitializeAsync_cycles_devices()
{
@@ -88,6 +94,7 @@ public sealed class AbCipDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies PLC family profiles expose expected default configuration values.</summary>
[Fact]
public void Family_profiles_expose_expected_defaults()
{
@@ -103,6 +110,7 @@ public sealed class AbCipDriverTests
AbCipPlcFamilyProfile.GuardLogix.LibplctagPlcAttribute.ShouldBe("controllogix");
}
/// <summary>Verifies AB CIP atomic data types map correctly to driver data types.</summary>
[Fact]
public void AbCipDataType_maps_atomics_to_driver_types()
{
@@ -34,6 +34,7 @@ public sealed class AbCipDriverWholeUdtReadTests
new AbCipStructureMember("Torque", AbCipDataType.Real), // offset 4
]);
/// <summary>Verifies that multiple members of the same UDT trigger only one parent read.</summary>
[Fact]
public async Task Two_members_of_same_udt_trigger_one_parent_read()
{
@@ -53,6 +54,7 @@ public sealed class AbCipDriverWholeUdtReadTests
factory.Tags["Motor"].ReadCount.ShouldBe(1);
}
/// <summary>Verifies that each UDT member is decoded at its correct offset.</summary>
[Fact]
public async Task Each_member_decodes_at_its_own_offset()
{
@@ -78,6 +80,7 @@ public sealed class AbCipDriverWholeUdtReadTests
snapshots[1].Value.ShouldBe(9.5f);
}
/// <summary>Verifies that parent read failure marks all grouped members as Bad.</summary>
[Fact]
public async Task Parent_read_failure_stamps_every_grouped_member_Bad()
{
@@ -97,6 +100,7 @@ public sealed class AbCipDriverWholeUdtReadTests
snapshots[1].Value.ShouldBeNull();
}
/// <summary>Verifies that mixed batches group UDT members and fall back to atomic reads.</summary>
[Fact]
public async Task Mixed_batch_groups_udt_and_falls_back_atomics()
{
@@ -116,6 +120,7 @@ public sealed class AbCipDriverWholeUdtReadTests
factory.Tags["PlainDint"].ReadCount.ShouldBe(1);
}
/// <summary>Verifies that a single UDT member uses the per-tag read path rather than grouping.</summary>
[Fact]
public async Task Single_member_of_Udt_uses_per_tag_read_path()
{
@@ -20,6 +20,7 @@ public sealed class AbCipDriverWriteTests
return (drv, factory);
}
/// <summary>Verifies that unknown reference maps to BadNodeIdUnknown status.</summary>
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
@@ -32,6 +33,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that non-writable tags map to BadNotWritable status.</summary>
[Fact]
public async Task Non_writable_tag_maps_to_BadNotWritable()
{
@@ -45,6 +47,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
}
/// <summary>Verifies that successful DInt writes encode and flush values.</summary>
[Fact]
public async Task Successful_DInt_write_encodes_and_flushes()
{
@@ -60,6 +63,7 @@ public sealed class AbCipDriverWriteTests
factory.Tags["Motor1.Speed"].WriteCount.ShouldBe(1);
}
/// <summary>Verifies that bit-in-DInt writes succeed via read-modify-write.</summary>
[Fact]
public async Task Bit_in_dint_write_now_succeeds_via_RMW()
{
@@ -80,6 +84,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>Verifies that non-zero libplctag status after write maps correctly.</summary>
[Fact]
public async Task Non_zero_libplctag_status_after_write_maps_via_AbCipStatusMapper()
{
@@ -94,6 +99,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
}
/// <summary>Verifies that type mismatch surfaces BadTypeMismatch status.</summary>
[Fact]
public async Task Type_mismatch_surfaces_BadTypeMismatch()
{
@@ -119,6 +125,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch);
}
/// <summary>Verifies that overflow surfaces BadOutOfRange status.</summary>
[Fact]
public async Task Overflow_surfaces_BadOutOfRange()
{
@@ -136,6 +143,7 @@ public sealed class AbCipDriverWriteTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange);
}
/// <summary>Verifies that exceptions during write surface BadCommunicationError.</summary>
[Fact]
public async Task Exception_during_write_surfaces_BadCommunicationError()
{
@@ -151,6 +159,7 @@ public sealed class AbCipDriverWriteTests
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>Verifies that batch write preserves order across success and failure.</summary>
[Fact]
public async Task Batch_preserves_order_across_success_and_failure()
{
@@ -182,6 +191,7 @@ public sealed class AbCipDriverWriteTests
results[3].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>Verifies that cancellation propagates from write operations.</summary>
[Fact]
public async Task Cancellation_propagates_from_write()
{
@@ -196,8 +206,10 @@ public sealed class AbCipDriverWriteTests
// ---- test-fake variants that exercise the real type / error handling ----
/// <summary>Test fake that uses real Convert methods to exercise type conversion errors.</summary>
private sealed class RealConvertFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
/// <inheritdoc />
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
switch (type)
@@ -212,6 +224,7 @@ public sealed class AbCipDriverWriteTests
private sealed class ThrowingBoolBitFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
/// <inheritdoc />
public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
if (type == AbCipDataType.Bool && bitIndex is not null)
@@ -220,14 +233,18 @@ public sealed class AbCipDriverWriteTests
}
}
/// <summary>Test fake that throws on write to simulate communication errors.</summary>
private sealed class ThrowOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
/// <inheritdoc />
public override Task WriteAsync(CancellationToken ct) =>
Task.FromException(new InvalidOperationException("wire dropped"));
}
/// <summary>Test fake that cancels during write to simulate cancellation.</summary>
private sealed class CancelOnWriteFake(AbCipTagCreateParams p) : FakeAbCipTag(p)
{
/// <inheritdoc />
public override Task WriteAsync(CancellationToken ct) =>
Task.FromException(new OperationCanceledException());
}
@@ -10,13 +10,26 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipFetchUdtShapeTests
{
/// <summary>Test implementation of IAbCipTemplateReader.</summary>
private sealed class FakeTemplateReader : IAbCipTemplateReader
{
/// <summary>Gets or sets the response bytes to return.</summary>
public byte[] Response { get; set; } = [];
/// <summary>Gets the count of read operations.</summary>
public int ReadCount { get; private set; }
/// <summary>Gets a value indicating whether the reader has been disposed.</summary>
public bool Disposed { get; private set; }
/// <summary>Gets the last template ID read.</summary>
public uint LastTemplateId { get; private set; }
/// <summary>Reads the template data for the specified device and template ID.</summary>
/// <param name="deviceParams">The device parameters.</param>
/// <param name="templateInstanceId">The template instance ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task that returns the template response bytes.</returns>
public Task<byte[]> ReadAsync(AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken ct)
{
ReadCount++;
@@ -24,14 +37,21 @@ public sealed class AbCipFetchUdtShapeTests
return Task.FromResult(Response);
}
/// <summary>Disposes the reader.</summary>
public void Dispose() => Disposed = true;
}
/// <summary>Test factory for creating fake template readers.</summary>
private sealed class FakeTemplateReaderFactory : IAbCipTemplateReaderFactory
{
/// <summary>Gets the list of created readers.</summary>
public List<IAbCipTemplateReader> Readers { get; } = new();
/// <summary>Gets or sets an optional customization function for reader creation.</summary>
public Func<IAbCipTemplateReader>? Customise { get; set; }
/// <summary>Creates a new template reader.</summary>
/// <returns>The created reader.</returns>
public IAbCipTemplateReader Create()
{
var r = Customise?.Invoke() ?? new FakeTemplateReader();
@@ -72,6 +92,7 @@ public sealed class AbCipFetchUdtShapeTests
return (Task<AbCipUdtShape?>)mi.Invoke(drv, [deviceHostAddress, templateId, CancellationToken.None])!;
}
/// <summary>Verifies that FetchUdtShapeAsync decodes a blob and caches the result.</summary>
[Fact]
public async Task FetchUdtShapeAsync_decodes_blob_and_caches_result()
{
@@ -101,6 +122,7 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.Count.ShouldBe(1);
}
/// <summary>Verifies that different template IDs result in separate fetch operations.</summary>
[Fact]
public async Task FetchUdtShapeAsync_different_templateIds_each_fetch()
{
@@ -131,6 +153,7 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.Count.ShouldBe(2);
}
/// <summary>Verifies that FetchUdtShapeAsync returns null for an unknown device.</summary>
[Fact]
public async Task FetchUdtShapeAsync_unknown_device_returns_null()
{
@@ -146,6 +169,7 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.ShouldBeEmpty();
}
/// <summary>Verifies that a decode failure returns null and does not cache the result.</summary>
[Fact]
public async Task FetchUdtShapeAsync_decode_failure_returns_null_and_does_not_cache()
{
@@ -168,6 +192,7 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.Count.ShouldBe(2);
}
/// <summary>Verifies that a reader exception returns null.</summary>
[Fact]
public async Task FetchUdtShapeAsync_reader_exception_returns_null()
{
@@ -185,6 +210,7 @@ public sealed class AbCipFetchUdtShapeTests
shape.ShouldBeNull();
}
/// <summary>Verifies that FlushOptionalCachesAsync empties the template cache.</summary>
[Fact]
public async Task FlushOptionalCachesAsync_empties_template_cache()
{
@@ -212,10 +238,18 @@ public sealed class AbCipFetchUdtShapeTests
factory.Readers.Count.ShouldBe(2);
}
/// <summary>Test implementation of IAbCipTemplateReader that throws on read.</summary>
private sealed class ThrowingTemplateReader : IAbCipTemplateReader
{
/// <summary>Throws an exception when read is attempted.</summary>
/// <param name="p">The device parameters.</param>
/// <param name="id">The template ID.</param>
/// <param name="ct">The cancellation token.</param>
/// <returns>Never returns; throws instead.</returns>
public Task<byte[]> ReadAsync(AbCipTagCreateParams p, uint id, CancellationToken ct) =>
throw new InvalidOperationException("fake read failure");
/// <summary>Disposes the reader.</summary>
public void Dispose() { }
}
}
@@ -7,6 +7,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipHostAddressTests
{
/// <summary>Verifies that TryParse accepts valid address forms.</summary>
/// <param name="input">The raw URI string to parse.</param>
/// <param name="gateway">The expected gateway host.</param>
/// <param name="port">The expected port number.</param>
/// <param name="cipPath">The expected CIP path.</param>
[Theory]
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
[InlineData("ab://10.0.0.5/1,4", "10.0.0.5", 44818, "1,4")]
@@ -25,6 +30,8 @@ public sealed class AbCipHostAddressTests
parsed.CipPath.ShouldBe(cipPath);
}
/// <summary>Verifies that TryParse rejects invalid address forms.</summary>
/// <param name="input">The invalid or null URI string to test.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -41,6 +48,11 @@ public sealed class AbCipHostAddressTests
AbCipHostAddress.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that ToString canonicalises the address format.</summary>
/// <param name="gateway">The gateway host component.</param>
/// <param name="port">The port number.</param>
/// <param name="path">The CIP path component.</param>
/// <param name="expected">The expected canonical string representation.</param>
[Theory]
[InlineData("10.0.0.5", 44818, "1,0", "ab://10.0.0.5/1,0")]
[InlineData("10.0.0.5", 2222, "1,0", "ab://10.0.0.5:2222/1,0")]
@@ -51,6 +63,7 @@ public sealed class AbCipHostAddressTests
addr.ToString().ShouldBe(expected);
}
/// <summary>Verifies that round-trip parsing and formatting is stable.</summary>
[Fact]
public void RoundTrip_is_stable()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipHostProbeTests
{
/// <summary>Verifies that GetHostStatuses returns one entry per configured device.</summary>
[Fact]
public async Task GetHostStatuses_returns_one_entry_per_device()
{
@@ -29,6 +30,7 @@ public sealed class AbCipHostProbeTests
statuses.ShouldAllBe(s => s.State == HostState.Unknown);
}
/// <summary>Verifies that a successful probe read transitions the host state to Running.</summary>
[Fact]
public async Task Probe_with_successful_read_transitions_to_Running()
{
@@ -55,6 +57,7 @@ public sealed class AbCipHostProbeTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that a failed probe read transitions the host state to Stopped.</summary>
[Fact]
public async Task Probe_with_read_failure_transitions_to_Stopped()
{
@@ -83,6 +86,7 @@ public sealed class AbCipHostProbeTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that the probe is disabled when the Enabled option is false.</summary>
[Fact]
public async Task Probe_disabled_when_Enabled_is_false()
{
@@ -103,6 +107,7 @@ public sealed class AbCipHostProbeTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that the probe is skipped when ProbeTagPath is null.</summary>
[Fact]
public async Task Probe_skipped_when_ProbeTagPath_is_null()
{
@@ -119,6 +124,7 @@ public sealed class AbCipHostProbeTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that the probe loops across multiple devices independently.</summary>
[Fact]
public async Task Probe_loops_across_multiple_devices_independently()
{
@@ -155,6 +161,7 @@ public sealed class AbCipHostProbeTests
// ---- IPerCallHostResolver ----
/// <summary>Verifies that ResolveHost returns the declared device for a known tag.</summary>
[Fact]
public async Task ResolveHost_returns_declared_device_for_known_tag()
{
@@ -178,6 +185,7 @@ public sealed class AbCipHostProbeTests
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
}
/// <summary>Verifies that ResolveHost falls back to the first device for an unknown tag reference.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_first_device_for_unknown_reference()
{
@@ -191,6 +199,7 @@ public sealed class AbCipHostProbeTests
drv.ResolveHost("does-not-exist").ShouldBe("ab://10.0.0.5/1,0");
}
/// <summary>Verifies that ResolveHost falls back to the driver instance ID when no devices are configured.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
{
@@ -200,6 +209,7 @@ public sealed class AbCipHostProbeTests
drv.ResolveHost("anything").ShouldBe("drv-1");
}
/// <summary>Verifies that ResolveHost for a UDT member walks to the synthesized definition.</summary>
[Fact]
public async Task ResolveHost_for_UDT_member_walks_to_synthesised_definition()
{
@@ -19,6 +19,7 @@ public sealed class AbCipLoggingTests
{
private const string Device = "ab://10.0.0.5/1,0";
/// <summary>Verifies that constructor accepts an ILogger.</summary>
[Fact]
public void Constructor_accepts_an_ILogger()
{
@@ -34,6 +35,7 @@ public sealed class AbCipLoggingTests
drv.ShouldNotBeNull();
}
/// <summary>Verifies that ProbeLoop logs when an exception is swallowed.</summary>
[Fact]
public async Task ProbeLoop_logs_when_an_exception_is_swallowed()
{
@@ -76,6 +78,7 @@ public sealed class AbCipLoggingTests
.ShouldBeTrue("at least one log entry should reference the probe loop or surface the swallowed exception");
}
/// <summary>Verifies that ReadFailure logs at warning level.</summary>
[Fact]
public async Task ReadFailure_logs_at_warning_level()
{
@@ -102,6 +105,7 @@ public sealed class AbCipLoggingTests
.ShouldBeTrue("read failure on tag 'Speed' should be logged at warning level or above");
}
/// <summary>Verifies that ReadException logs at warning level.</summary>
[Fact]
public async Task ReadException_logs_at_warning_level()
{
@@ -132,6 +136,7 @@ public sealed class AbCipLoggingTests
.ShouldBeTrue("read transport exception should be logged at warning level with the inner exception attached");
}
/// <summary>Verifies that InitializeAsync warns when probe is enabled but ProbeTagPath is blank.</summary>
[Fact]
public async Task InitializeAsync_warns_when_probe_is_enabled_but_ProbeTagPath_is_blank()
{
@@ -158,6 +163,7 @@ public sealed class AbCipLoggingTests
.ShouldBeTrue("probe-enabled-but-inert configuration should be logged at warning level");
}
/// <summary>Verifies that InitializeAsync does not warn when probe is disabled.</summary>
[Fact]
public async Task InitializeAsync_does_not_warn_when_probe_is_disabled()
{
@@ -176,12 +182,26 @@ public sealed class AbCipLoggingTests
.ShouldBeFalse("no probe warning expected when Probe.Enabled is false");
}
/// <summary>Test logger that captures all log entries.</summary>
internal sealed class CapturingLogger<T> : ILogger<T>
{
/// <summary>Gets the captured log entries.</summary>
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
/// <summary>Begins a scope (stub implementation).</summary>
/// <typeparam name="TState">The type of the scope state.</typeparam>
/// <param name="state">The scope state.</param>
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <summary>Checks if logging is enabled (always true).</summary>
/// <param name="logLevel">The log level to check.</param>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Logs an entry and captures it.</summary>
/// <typeparam name="TState">The type of the log state.</typeparam>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event ID.</param>
/// <param name="state">The log state.</param>
/// <param name="exception">The exception, if any.</param>
/// <param name="formatter">The log message formatter.</param>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
@@ -190,7 +210,9 @@ public sealed class AbCipLoggingTests
private sealed class NullScope : IDisposable
{
/// <summary>Gets the singleton instance.</summary>
public static NullScope Instance { get; } = new();
/// <summary>Disposes the scope (stub implementation).</summary>
public void Dispose() { }
}
}
@@ -20,6 +20,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
{
private const string Device = "ab://10.0.0.5/1,0";
/// <summary>Verifies that per-device AllowPacking override is forwarded to tag creation parameters.</summary>
[Fact]
public async Task Device_AllowPacking_override_is_forwarded_to_tag_create_params()
{
@@ -38,6 +39,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.AllowPacking.ShouldBeFalse();
}
/// <summary>Verifies that AllowPacking defaults inherit from the family profile when not overridden.</summary>
[Fact]
public async Task Device_AllowPacking_default_inherits_from_family_profile()
{
@@ -58,6 +60,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.AllowPacking.ShouldBeTrue();
}
/// <summary>Verifies that Micro800 devices have AllowPacking defaulting to false from the family profile.</summary>
[Fact]
public async Task Micro800_default_AllowPacking_is_false_from_family_profile()
{
@@ -77,6 +80,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["X"].CreationParams.AllowPacking.ShouldBeFalse();
}
/// <summary>Verifies that per-device ConnectionSize override is forwarded to tag creation parameters.</summary>
[Fact]
public async Task Device_ConnectionSize_override_is_forwarded_to_tag_create_params()
{
@@ -94,6 +98,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(504);
}
/// <summary>Verifies that ConnectionSize defaults inherit from the family profile when not overridden.</summary>
[Fact]
public async Task Device_ConnectionSize_default_inherits_from_family_profile()
{
@@ -112,6 +117,7 @@ public sealed class AbCipPerDeviceConnectionOptionsTests
factory.Tags["Speed"].CreationParams.ConnectionSize.ShouldBe(4002);
}
/// <summary>Verifies that AllowPacking and ConnectionSize round-trip correctly through JSON parsing.</summary>
[Fact]
public void AbCipDriverFactoryExtensions_ParseOptions_round_trips_AllowPacking_and_ConnectionSize()
{
@@ -11,6 +11,7 @@ public sealed class AbCipPlcFamilyTests
{
// ---- ControlLogix ----
/// <summary>Verifies that the ControlLogix profile defaults match the large forward open baseline.</summary>
[Fact]
public void ControlLogix_profile_defaults_match_large_forward_open_baseline()
{
@@ -23,6 +24,7 @@ public sealed class AbCipPlcFamilyTests
p.MaxFragmentBytes.ShouldBe(4000);
}
/// <summary>Verifies that a ControlLogix device initializes with the correct profile.</summary>
[Fact]
public async Task ControlLogix_device_initialises_with_correct_profile()
{
@@ -38,6 +40,7 @@ public sealed class AbCipPlcFamilyTests
// ---- CompactLogix ----
/// <summary>Verifies that the CompactLogix profile uses a narrower connection size than ControlLogix.</summary>
[Fact]
public void CompactLogix_profile_uses_narrower_connection_size()
{
@@ -50,6 +53,7 @@ public sealed class AbCipPlcFamilyTests
p.MaxFragmentBytes.ShouldBe(500);
}
/// <summary>Verifies that a CompactLogix device initializes with a narrow connection size.</summary>
[Fact]
public async Task CompactLogix_device_initialises_with_narrow_ConnectionSize()
{
@@ -67,6 +71,7 @@ public sealed class AbCipPlcFamilyTests
// ---- Micro800 ----
/// <summary>Verifies that the Micro800 profile is unconnected only and supports an empty CIP path.</summary>
[Fact]
public void Micro800_profile_is_unconnected_only_with_empty_path()
{
@@ -79,6 +84,7 @@ public sealed class AbCipPlcFamilyTests
p.MaxFragmentBytes.ShouldBe(484);
}
/// <summary>Verifies that a Micro800 device with an empty CIP path parses correctly.</summary>
[Fact]
public async Task Micro800_device_with_empty_cip_path_parses_correctly()
{
@@ -95,6 +101,7 @@ public sealed class AbCipPlcFamilyTests
state.Profile.SupportsConnectedMessaging.ShouldBeFalse();
}
/// <summary>Verifies that Micro800 read operations forward the empty path to tag creation parameters.</summary>
[Fact]
public async Task Micro800_read_forwards_empty_path_to_tag_create_params()
{
@@ -114,6 +121,7 @@ public sealed class AbCipPlcFamilyTests
// ---- GuardLogix ----
/// <summary>Verifies that the GuardLogix profile wire protocol mirrors ControlLogix.</summary>
[Fact]
public void GuardLogix_profile_wire_protocol_mirrors_ControlLogix()
{
@@ -125,6 +133,7 @@ public sealed class AbCipPlcFamilyTests
p.DefaultCipPath.ShouldBe(AbCipPlcFamilyProfile.ControlLogix.DefaultCipPath);
}
/// <summary>Verifies that GuardLogix safety tags surface as ViewOnly in discovery.</summary>
[Fact]
public async Task GuardLogix_safety_tag_surfaces_as_ViewOnly_in_discovery()
{
@@ -150,6 +159,7 @@ public sealed class AbCipPlcFamilyTests
.ShouldBe(SecurityClassification.ViewOnly);
}
/// <summary>Verifies that GuardLogix safety tag writes are rejected even when the tag is marked Writable.</summary>
[Fact]
public async Task GuardLogix_safety_tag_writes_rejected_even_when_Writable_is_true()
{
@@ -174,6 +184,9 @@ public sealed class AbCipPlcFamilyTests
// ---- ForFamily dispatch ----
/// <summary>Verifies that ForFamily dispatches to the correct profile for each PLC family.</summary>
/// <param name="family">The AB CIP PLC family to test.</param>
/// <param name="expectedAttribute">The expected libplctag PLC attribute string.</param>
[Theory]
[InlineData(AbCipPlcFamily.ControlLogix, "controllogix")]
[InlineData(AbCipPlcFamily.CompactLogix, "compactlogix")]
@@ -188,22 +201,43 @@ public sealed class AbCipPlcFamilyTests
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of folders recorded by this builder.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the list of variables recorded by this builder.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Adds a folder to the recorded list and returns this builder for chaining.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Adds a variable to the recorded list and returns a handle.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="info">The driver attribute information.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>No-op property adding operation for test compatibility.</summary>
/// <param name="_">The property name.</param>
/// <param name="__">The property data type.</param>
/// <param name="___">The property value.</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference for this variable handle.</summary>
public string FullReference => fullRef;
/// <summary>Marks this variable as an alarm condition and returns a null sink.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
private sealed class NullSink : IAlarmConditionSink
{
/// <summary>Called when an alarm state transitions.</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -8,6 +8,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipStatusMapperTests
{
/// <summary>Verifies that CIP general status codes are mapped to OPC UA status codes.</summary>
/// <param name="status">The raw CIP general status byte.</param>
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData((byte)0x00, AbCipStatusMapper.Good)]
[InlineData((byte)0x04, AbCipStatusMapper.BadNodeIdUnknown)]
@@ -29,6 +32,9 @@ public sealed class AbCipStatusMapperTests
// Driver.AbCip-002 — the integers here are the underlying values of the libplctag.NET
// Status enum (what (int)Tag.GetStatus() actually returns), NOT raw native PLCTAG_ERR_*
// constants. The libplctag.NET wrapper renumbers the native codes into a contiguous enum.
/// <summary>Verifies that libplctag Status enum values are mapped to OPC UA status codes.</summary>
/// <param name="status">The libplctag Status enum value to map.</param>
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData(Status.Ok, AbCipStatusMapper.Good)]
[InlineData(Status.Pending, AbCipStatusMapper.GoodMoreData)]
@@ -47,6 +53,7 @@ public sealed class AbCipStatusMapperTests
AbCipStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected);
}
/// <summary>Verifies that timeout is distinguished from generic communication error.</summary>
[Fact]
public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error()
{
@@ -20,6 +20,7 @@ public sealed class AbCipSubscriptionTests
return (drv, factory);
}
/// <summary>Verifies that the initial poll raises OnDataChange events for every subscribed tag.</summary>
[Fact]
public async Task Initial_poll_raises_OnDataChange_for_every_tag()
{
@@ -45,6 +46,7 @@ public sealed class AbCipSubscriptionTests
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
/// <summary>Verifies that unchanged values raise OnDataChange only once (on initial poll).</summary>
[Fact]
public async Task Unchanged_value_raises_only_once()
{
@@ -63,6 +65,7 @@ public sealed class AbCipSubscriptionTests
events.Count.ShouldBe(1);
}
/// <summary>Verifies that value changes between polls raise OnDataChange events.</summary>
[Fact]
public async Task Value_change_between_polls_raises_OnDataChange()
{
@@ -85,6 +88,7 @@ public sealed class AbCipSubscriptionTests
events.Last().Snapshot.Value.ShouldBe(200);
}
/// <summary>Verifies that unsubscribe halts polling and no further events are raised.</summary>
[Fact]
public async Task Unsubscribe_halts_polling()
{
@@ -107,6 +111,7 @@ public sealed class AbCipSubscriptionTests
events.Count.ShouldBe(afterUnsub);
}
/// <summary>Verifies that polling intervals below 100ms are floored to the minimum.</summary>
[Fact]
public async Task Interval_below_100ms_is_floored()
{
@@ -127,6 +132,7 @@ public sealed class AbCipSubscriptionTests
events.Count.ShouldBe(1);
}
/// <summary>Verifies that ShutdownAsync cancels all active subscriptions.</summary>
[Fact]
public async Task ShutdownAsync_cancels_active_subscriptions()
{
@@ -147,6 +153,7 @@ public sealed class AbCipSubscriptionTests
events.Count.ShouldBe(afterShutdown);
}
/// <summary>Verifies that subscriptions on UDT members use the synthesized full reference.</summary>
[Fact]
public async Task Subscription_on_UDT_member_uses_synthesised_full_reference()
{
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipTagPathTests
{
/// <summary>Verifies that a controller-scope single-segment tag path parses correctly.</summary>
[Fact]
public void Controller_scope_single_segment()
{
@@ -20,6 +21,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Motor1_Speed");
}
/// <summary>Verifies that a program-scope tag path parses correctly.</summary>
[Fact]
public void Program_scope_parses()
{
@@ -30,6 +32,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Program:MainProgram.StepIndex");
}
/// <summary>Verifies that structured member access splits into multiple segments.</summary>
[Fact]
public void Structured_member_access_splits_segments()
{
@@ -39,6 +42,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Motor1.Speed.Setpoint");
}
/// <summary>Verifies that single-dimensional array subscript is parsed correctly.</summary>
[Fact]
public void Single_dim_array_subscript()
{
@@ -49,6 +53,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Data[7]");
}
/// <summary>Verifies that multi-dimensional array subscript is parsed correctly.</summary>
[Fact]
public void Multi_dim_array_subscript()
{
@@ -58,6 +63,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Matrix[1,2,3]");
}
/// <summary>Verifies that bit index in DINT is captured correctly.</summary>
[Fact]
public void Bit_in_dint_captured_as_bit_index()
{
@@ -68,6 +74,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Flags.3");
}
/// <summary>Verifies that bit index after member access is captured correctly.</summary>
[Fact]
public void Bit_in_dint_after_member()
{
@@ -78,6 +85,7 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Motor.Status.12");
}
/// <summary>Verifies that bit index 32 is rejected as out of range.</summary>
[Fact]
public void Bit_index_32_rejected_out_of_range()
{
@@ -86,6 +94,7 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse("Flags.32").ShouldBeNull();
}
/// <summary>Verifies that program scope with members, subscript, and bit index parses correctly.</summary>
[Fact]
public void Program_scope_with_members_and_subscript_and_bit()
{
@@ -98,6 +107,8 @@ public sealed class AbCipTagPathTests
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors[0].Status.5");
}
/// <summary>Verifies that invalid tag path shapes return null.</summary>
/// <param name="input">The input string to test for invalid shapes.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -117,12 +128,14 @@ public sealed class AbCipTagPathTests
AbCipTagPath.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that identifiers with underscores are accepted.</summary>
[Fact]
public void Ident_with_underscore_accepted()
{
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
}
/// <summary>Verifies that ToLibplctagName recomposes a round-trip correctly.</summary>
[Fact]
public void ToLibplctagName_recomposes_round_trip()
{
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtMemberLayoutTests
{
/// <summary>Verifies that packed atomic types get natural alignment offsets.</summary>
[Fact]
public void Packed_Atomics_Get_Natural_Alignment_Offsets()
{
@@ -27,6 +28,7 @@ public sealed class AbCipUdtMemberLayoutTests
offsets["D"].ShouldBe(16);
}
/// <summary>Verifies that signed integer types are packed without padding.</summary>
[Fact]
public void SInt_Packed_Without_Padding()
{
@@ -42,6 +44,7 @@ public sealed class AbCipUdtMemberLayoutTests
offsets["Z"].ShouldBe(2);
}
/// <summary>Verifies that layout returns null when a member is a Bool type.</summary>
[Fact]
public void Returns_Null_When_Member_Is_Bool()
{
@@ -55,6 +58,7 @@ public sealed class AbCipUdtMemberLayoutTests
AbCipUdtMemberLayout.TryBuild(members).ShouldBeNull();
}
/// <summary>Verifies that layout returns null when a member is a String or Structure type.</summary>
[Fact]
public void Returns_Null_When_Member_Is_String_Or_Structure()
{
@@ -64,6 +68,7 @@ public sealed class AbCipUdtMemberLayoutTests
new[] { new AbCipStructureMember("Nested", AbCipDataType.Structure) }).ShouldBeNull();
}
/// <summary>Verifies that layout returns null when the member list is empty.</summary>
[Fact]
public void Returns_Null_On_Empty_Members()
{
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
[Trait("Category", "Unit")]
public sealed class AbCipUdtMemberTests
{
/// <summary>Verifies that UDT with declared members expands to individual member variables.</summary>
[Fact]
public async Task UDT_with_declared_members_fans_out_to_member_variables()
{
@@ -46,6 +47,7 @@ public sealed class AbCipUdtMemberTests
.ShouldBeTrue();
}
/// <summary>Verifies that UDT members can be read via synthesised full reference paths.</summary>
[Fact]
public async Task UDT_members_resolvable_for_read_via_synthesised_full_reference()
{
@@ -81,6 +83,7 @@ public sealed class AbCipUdtMemberTests
snapshots[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
}
/// <summary>Verifies that UDT member writes route through synthesised tag paths.</summary>
[Fact]
public async Task UDT_member_write_routes_through_synthesised_tagpath()
{
@@ -106,6 +109,7 @@ public sealed class AbCipUdtMemberTests
factory.Tags["Motor1.SetPoint"].Value.ShouldBe(42.5f);
}
/// <summary>Verifies that UDT member read/write operations respect the Writable flag.</summary>
[Fact]
public async Task UDT_member_read_write_honours_member_Writable_flag()
{
@@ -130,6 +134,7 @@ public sealed class AbCipUdtMemberTests
results.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
}
/// <summary>Verifies that structure tags without declared members appear as single variables.</summary>
[Fact]
public async Task Structure_tag_without_members_is_emitted_as_single_variable()
{
@@ -150,6 +155,7 @@ public sealed class AbCipUdtMemberTests
builder.Folders.ShouldNotContain(f => f.BrowseName == "OpaqueUdt");
}
/// <summary>Verifies that empty member lists are treated the same as null.</summary>
[Fact]
public async Task Empty_Members_list_is_treated_like_null()
{
@@ -167,6 +173,7 @@ public sealed class AbCipUdtMemberTests
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
}
/// <summary>Verifies that UDT members and flat tags can coexist in the address space.</summary>
[Fact]
public async Task UDT_members_mixed_with_flat_tags_coexist()
{
@@ -194,24 +201,48 @@ public sealed class AbCipUdtMemberTests
// ---- helpers ----
/// <summary>Recording builder for testing address space construction.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the collected folders.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the collected variables.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Records a folder in the address space.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Records a variable in the address space.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="info">The driver attribute information for the variable.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>Records a property (stub implementation for testing).</summary>
/// <param name="_">The property name (unused in this stub).</param>
/// <param name="__">The property data type (unused in this stub).</param>
/// <param name="___">The property value (unused in this stub).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
/// <summary>Variable handle implementation for testing.</summary>
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference path.</summary>
public string FullReference => fullRef;
/// <summary>Marks this handle as an alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
/// <summary>Null alarm condition sink for testing.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <summary>Handles alarm transitions (stub).</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -8,6 +8,7 @@ public sealed class AbCipUdtReadPlannerTests
{
private const string Device = "ab://10.0.0.1/1,0";
/// <summary>Verifies two members of the same UDT parent are grouped.</summary>
[Fact]
public void Groups_Two_Members_Of_The_Same_Udt_Parent()
{
@@ -20,6 +21,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks.Count.ShouldBe(0);
}
/// <summary>Verifies single member reference falls back to per-tag path.</summary>
[Fact]
public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path()
{
@@ -33,6 +35,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
}
/// <summary>Verifies unknown references fall back without affecting groups.</summary>
[Fact]
public void Unknown_References_Fall_Back_Without_Affecting_Groups()
{
@@ -47,6 +50,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember");
}
/// <summary>Verifies atomic top-level tags fall back untouched.</summary>
[Fact]
public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
{
@@ -62,6 +66,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
}
/// <summary>Verifies UDT with bool member does not group.</summary>
[Fact]
public void Udt_With_Bool_Member_Does_Not_Group()
{
@@ -88,6 +93,7 @@ public sealed class AbCipUdtReadPlannerTests
plan.Fallbacks.Count.ShouldBe(2);
}
/// <summary>Verifies original indices are preserved for out-of-order batches.</summary>
[Fact]
public void Original_Indices_Preserved_For_Out_Of_Order_Batches()
{
@@ -50,6 +50,7 @@ public sealed class CipSymbolObjectDecoderTests
return result;
}
/// <summary>Verifies that a single DInt entry decodes correctly.</summary>
[Fact]
public void Single_DInt_entry_decodes_to_scalar_DInt_tag()
{
@@ -69,6 +70,9 @@ public sealed class CipSymbolObjectDecoderTests
tags[0].IsSystemTag.ShouldBeFalse();
}
/// <summary>Verifies that all known atomic type codes map to correct data types.</summary>
/// <param name="typeCode">The CIP type code to map.</param>
/// <param name="expected">The expected AbCipDataType result.</param>
[Theory]
[InlineData((byte)0xC1, AbCipDataType.Bool)]
[InlineData((byte)0xC2, AbCipDataType.SInt)]
@@ -87,12 +91,14 @@ public sealed class CipSymbolObjectDecoderTests
CipSymbolObjectDecoder.MapTypeCode(typeCode).ShouldBe(expected);
}
/// <summary>Verifies that unknown type codes return null for opaque handling.</summary>
[Fact]
public void Unknown_type_code_returns_null_so_caller_treats_as_opaque()
{
CipSymbolObjectDecoder.MapTypeCode(0xFF).ShouldBeNull();
}
/// <summary>Verifies that struct flag overrides type code.</summary>
[Fact]
public void Struct_flag_overrides_type_code_and_yields_Structure()
{
@@ -108,6 +114,7 @@ public sealed class CipSymbolObjectDecoderTests
tag.DataType.ShouldBe(AbCipDataType.Structure);
}
/// <summary>Verifies that system flag surfaces as IsSystemTag true.</summary>
[Fact]
public void System_flag_surfaces_as_IsSystemTag_true()
{
@@ -123,6 +130,7 @@ public sealed class CipSymbolObjectDecoderTests
tag.DataType.ShouldBe(AbCipDataType.DInt);
}
/// <summary>Verifies that program scope names split correctly into prefix and name.</summary>
[Fact]
public void Program_scope_name_splits_prefix_into_ProgramScope()
{
@@ -138,6 +146,7 @@ public sealed class CipSymbolObjectDecoderTests
tag.Name.ShouldBe("StepIndex");
}
/// <summary>Verifies that multiple entries decode in wire order with proper padding.</summary>
[Fact]
public void Multiple_entries_decode_in_wire_order_with_even_padding()
{
@@ -154,6 +163,7 @@ public sealed class CipSymbolObjectDecoderTests
tags[1].DataType.ShouldBe(AbCipDataType.Real);
}
/// <summary>Verifies that truncated buffers stop decoding gracefully.</summary>
[Fact]
public void Truncated_buffer_stops_decoding_gracefully()
{
@@ -164,12 +174,17 @@ public sealed class CipSymbolObjectDecoderTests
CipSymbolObjectDecoder.Decode(truncated).ToList().Count.ShouldBeLessThan(1); // 0 — didn't parse the broken entry
}
/// <summary>Verifies that empty buffers yield no tags.</summary>
[Fact]
public void Empty_buffer_yields_no_tags()
{
CipSymbolObjectDecoder.Decode([]).ShouldBeEmpty();
}
/// <summary>Verifies that SplitProgramScope handles all valid name shapes.</summary>
/// <param name="input">The name string to split.</param>
/// <param name="expectedScope">The expected program scope prefix, if any.</param>
/// <param name="expectedName">The expected name after splitting.</param>
[Theory]
[InlineData("Counter", null, "Counter")]
[InlineData("Program:MainProgram.Step", "MainProgram", "Step")]
@@ -50,6 +50,7 @@ public sealed class CipTemplateObjectDecoderTests
return buf;
}
/// <summary>Verifies that a simple UDT with two members decodes correctly.</summary>
[Fact]
public void Simple_two_member_UDT_decodes_correctly()
{
@@ -72,6 +73,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members[1].Offset.ShouldBe(4);
}
/// <summary>Verifies that the struct member flag is decoded as Structure type.</summary>
[Fact]
public void Struct_member_flag_surfaces_Structure_type()
{
@@ -84,6 +86,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
/// <summary>Verifies that array members carry correct non-one array lengths.</summary>
[Fact]
public void Array_member_carries_non_one_ArrayLength()
{
@@ -95,6 +98,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members.Single().ArrayLength.ShouldBe(10);
}
/// <summary>Verifies that multiple atomic types preserve their offsets and type information.</summary>
[Fact]
public void Multiple_atomic_types_preserve_offsets_and_types()
{
@@ -116,6 +120,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members.Select(m => m.Offset).ShouldBe([0, 1, 2, 4, 8, 16]);
}
/// <summary>Verifies that unknown atomic type codes fall back to Structure type.</summary>
[Fact]
public void Unknown_atomic_type_code_falls_back_to_Structure()
{
@@ -127,6 +132,7 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members.Single().DataType.ShouldBe(AbCipDataType.Structure);
}
/// <summary>Verifies that zero member count returns null.</summary>
[Fact]
public void Zero_member_count_returns_null()
{
@@ -135,12 +141,14 @@ public sealed class CipTemplateObjectDecoderTests
CipTemplateObjectDecoder.Decode(buf).ShouldBeNull();
}
/// <summary>Verifies that a short buffer returns null.</summary>
[Fact]
public void Short_buffer_returns_null()
{
CipTemplateObjectDecoder.Decode([0x01, 0x00]).ShouldBeNull(); // only 2 bytes — less than header
}
/// <summary>Verifies that missing member names surface a placeholder.</summary>
[Fact]
public void Missing_member_name_surfaces_placeholder()
{
@@ -165,6 +173,9 @@ public sealed class CipTemplateObjectDecoderTests
shape.Members[2].Name.ShouldBe("<member_2>");
}
/// <summary>Verifies that semicolon-terminated string parsing handles various input shapes.</summary>
/// <param name="input">The raw semicolon-terminated string input.</param>
/// <param name="expected">The expected array of parsed strings.</param>
[Theory]
[InlineData("Foo;\0Bar;\0", new[] { "Foo", "Bar" })]
[InlineData("Foo;Bar;", new[] { "Foo", "Bar" })] // no nulls
@@ -10,19 +10,33 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// </summary>
internal class FakeAbCipTag : IAbCipTagRuntime
{
/// <summary>Gets the tag creation parameters.</summary>
public AbCipTagCreateParams CreationParams { get; }
/// <summary>Gets or sets the mock tag value.</summary>
public object? Value { get; set; }
/// <summary>Gets or sets the simulated libplctag status code.</summary>
public int Status { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on <see cref="InitializeAsync"/>.</summary>
public bool ThrowOnInitialize { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on <see cref="ReadAsync"/>.</summary>
public bool ThrowOnRead { get; set; }
/// <summary>Gets or sets the exception to throw when simulation flags are set.</summary>
public Exception? Exception { get; set; }
/// <summary>Gets the count of <see cref="InitializeAsync"/> invocations.</summary>
public int InitializeCount { get; private set; }
/// <summary>Gets the count of <see cref="ReadAsync"/> invocations.</summary>
public int ReadCount { get; private set; }
/// <summary>Gets the count of <see cref="WriteAsync"/> invocations.</summary>
public int WriteCount { get; private set; }
/// <summary>Gets a value indicating whether the tag has been disposed.</summary>
public bool Disposed { get; private set; }
/// <summary>Initializes a new instance of the <see cref="FakeAbCipTag"/> class.</summary>
/// <param name="createParams">The tag creation parameters.</param>
public FakeAbCipTag(AbCipTagCreateParams createParams) => CreationParams = createParams;
/// <summary>Increments the initialize count and simulates initialization.</summary>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public virtual Task InitializeAsync(CancellationToken cancellationToken)
{
InitializeCount++;
@@ -30,6 +44,8 @@ internal class FakeAbCipTag : IAbCipTagRuntime
return Task.CompletedTask;
}
/// <summary>Increments the read count and simulates a read operation.</summary>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public virtual Task ReadAsync(CancellationToken cancellationToken)
{
ReadCount++;
@@ -37,14 +53,20 @@ internal class FakeAbCipTag : IAbCipTagRuntime
return Task.CompletedTask;
}
/// <summary>Increments the write count and simulates a write operation.</summary>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public virtual Task WriteAsync(CancellationToken cancellationToken)
{
WriteCount++;
return Task.CompletedTask;
}
/// <summary>Returns the simulated status code.</summary>
public virtual int GetStatus() => Status;
/// <summary>Returns the mock tag value.</summary>
/// <param name="type">The data type being decoded.</param>
/// <param name="bitIndex">The optional bit index for bit operations.</param>
public virtual object? DecodeValue(AbCipDataType type, int? bitIndex) => Value;
/// <summary>
@@ -56,23 +78,36 @@ internal class FakeAbCipTag : IAbCipTagRuntime
/// </summary>
public Dictionary<int, object?> ValuesByOffset { get; } = new();
/// <summary>Returns the mock value at the specified offset.</summary>
/// <param name="type">The data type being decoded.</param>
/// <param name="offset">The byte offset into the tag storage.</param>
/// <param name="bitIndex">The optional bit index for bit operations.</param>
public virtual object? DecodeValueAt(AbCipDataType type, int offset, int? bitIndex)
{
if (ValuesByOffset.TryGetValue(offset, out var v)) return v;
return offset == 0 ? Value : null;
}
/// <summary>Encodes a value into the mock tag storage.</summary>
/// <param name="type">The data type being encoded.</param>
/// <param name="bitIndex">The optional bit index for bit operations.</param>
/// <param name="value">The value to encode.</param>
public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value;
/// <summary>Marks the tag as disposed.</summary>
public virtual void Dispose() => Disposed = true;
}
/// <summary>Test factory that produces <see cref="FakeAbCipTag"/>s and indexes them for assertion.</summary>
internal sealed class FakeAbCipTagFactory : IAbCipTagFactory
{
/// <summary>Gets a dictionary of created tags indexed by tag name for assertion.</summary>
public Dictionary<string, FakeAbCipTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets or sets an optional customization function to override the tag creation.</summary>
public Func<AbCipTagCreateParams, FakeAbCipTag>? Customise { get; set; }
/// <summary>Creates a new fake tag and indexes it by name.</summary>
/// <param name="createParams">The tag creation parameters.</param>
public IAbCipTagRuntime Create(AbCipTagCreateParams createParams)
{
var fake = Customise?.Invoke(createParams) ?? new FakeAbCipTag(createParams);
@@ -22,6 +22,7 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
// filters to that profile alone so the suite matches the running container. Unset
// (the default for real-hardware runs) parameterises across every family the driver
// supports.
/// <summary>Gets the available server profiles based on compose configuration.</summary>
public static IEnumerable<object[]> Profiles
{
get
@@ -34,6 +35,8 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
}
}
/// <summary>Verifies that the driver reads seeded N file from the AB server via PCCC.</summary>
/// <param name="profile">The AB Legacy server profile describing the fixture endpoint.</param>
[AbLegacyTheory]
[MemberData(nameof(Profiles))]
public async Task Driver_reads_seeded_N_file_from_ab_server_PCCC(AbLegacyServerProfile profile)
@@ -69,6 +72,7 @@ public sealed class AbLegacyReadSmokeTests(AbLegacyServerFixture sim)
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies that SLC500 write-then-read round trip succeeds on N7 scratch register.</summary>
[AbLegacyFact]
public async Task Slc500_write_then_read_round_trip_on_N7_scratch_register()
{
@@ -53,15 +53,20 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
// 10.100.0.35 = the shared Docker host (see CLAUDE.md "Docker Workflow"). Migrated
// off this VM's 127.0.0.1 on 2026-04-28 alongside the rest of the Docker-host move.
// Override via AB_LEGACY_ENDPOINT to point at a real PLC or a locally-running container.
/// <summary>Gets the server host address.</summary>
public string Host { get; } = "10.100.0.35";
/// <summary>Gets the server TCP port.</summary>
public int Port { get; } = DefaultPort;
/// <summary>CIP routing path portion of the device URI (after the <c>/</c> separator).
/// May be empty when targeting real hardware; non-empty against ab_server.</summary>
public string CipPath { get; } = DefaultCipPath;
/// <summary>Gets the skip reason if the server is unavailable, or null if available.</summary>
public string? SkipReason { get; }
/// <summary>Initializes the AB Legacy server fixture by reading environment overrides.</summary>
public AbLegacyServerFixture()
{
if (Environment.GetEnvironmentVariable(EndpointEnvVar) is { Length: > 0 } raw)
@@ -79,7 +84,10 @@ public sealed class AbLegacyServerFixture : IAsyncLifetime
SkipReason = ResolveSkipReason(Host, Port);
}
/// <summary>Initializes the fixture asynchronously.</summary>
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
/// <summary>Disposes the fixture asynchronously.</summary>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
/// <summary>
@@ -155,9 +163,12 @@ public static class KnownProfiles
ComposeProfile: "plc5",
Notes: "PLC-5 family. ab_server PLC/5 mode covers N/F/B; per-family quirks on ST / timer file layouts unit-tested only.");
/// <summary>Gets all known profiles.</summary>
public static IReadOnlyList<AbLegacyServerProfile> All { get; } =
[Slc500, MicroLogix, Plc5];
/// <summary>Gets the profile for the specified PLC family.</summary>
/// <param name="family">The PLC family.</param>
public static AbLegacyServerProfile ForFamily(AbLegacyPlcFamily family) =>
All.FirstOrDefault(p => p.Family == family)
?? throw new ArgumentOutOfRangeException(nameof(family), family, "No integration profile for this family.");
@@ -175,6 +186,7 @@ public sealed class AbLegacyServerCollection : Xunit.ICollectionFixture<AbLegacy
/// </summary>
public sealed class AbLegacyFactAttribute : FactAttribute
{
/// <summary>Initializes the attribute, skipping the test if the server is unavailable.</summary>
public AbLegacyFactAttribute()
{
if (!AbLegacyServerFixture.IsServerAvailable())
@@ -189,6 +201,7 @@ public sealed class AbLegacyFactAttribute : FactAttribute
/// </summary>
public sealed class AbLegacyTheoryAttribute : TheoryAttribute
{
/// <summary>Initializes the attribute, skipping the test if the server is unavailable.</summary>
public AbLegacyTheoryAttribute()
{
if (!AbLegacyServerFixture.IsServerAvailable())
@@ -7,6 +7,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
[Trait("Category", "Unit")]
public sealed class AbLegacyAddressTests
{
/// <summary>Verifies that TryParse accepts valid PCCC addresses.</summary>
/// <param name="input">The raw PCCC address string to parse.</param>
/// <param name="letter">The expected file letter.</param>
/// <param name="file">The expected file number, or null for I/O/S files.</param>
/// <param name="word">The expected word number.</param>
/// <param name="bit">The expected bit index, or null if no bit is specified.</param>
/// <param name="sub">The expected sub-element name, or null if absent.</param>
[Theory]
[InlineData("N7:0", "N", 7, 0, null, null)]
[InlineData("N7:15", "N", 7, 15, null, null)]
@@ -34,6 +41,8 @@ public sealed class AbLegacyAddressTests
a.SubElement.ShouldBe(sub);
}
/// <summary>Verifies that TryParse rejects invalid address forms.</summary>
/// <param name="input">The invalid address string to test, or null.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -51,6 +60,8 @@ public sealed class AbLegacyAddressTests
AbLegacyAddress.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that ToLibplctagName correctly roundtrips addresses.</summary>
/// <param name="input">The PCCC address string to roundtrip.</param>
[Theory]
[InlineData("N7:0")]
[InlineData("F8:5")]
@@ -68,12 +79,16 @@ public sealed class AbLegacyAddressTests
// ---- Driver.AbLegacy-003: Parser tightening ----
/// <summary>Verifies that TryParse rejects addresses with both sub-element and bit index.</summary>
/// <param name="input">The PCCC address string to test.</param>
[Theory]
[InlineData("T4:0.ACC/2")] // sub-element + bit index — never valid in PCCC
[InlineData("C5:0.PRE/3")]
public void TryParse_rejects_subelement_plus_bitindex(string input) =>
AbLegacyAddress.TryParse(input).ShouldBeNull();
/// <summary>Verifies that TryParse rejects file numbers on system files (I, O, S).</summary>
/// <param name="input">The PCCC address string to test.</param>
[Theory]
[InlineData("I3:0")] // I is a system file — no file number allowed
[InlineData("O2:1")]
@@ -81,6 +96,8 @@ public sealed class AbLegacyAddressTests
public void TryParse_rejects_file_number_on_IOS_files(string input) =>
AbLegacyAddress.TryParse(input).ShouldBeNull();
/// <summary>Verifies that TryParse rejects sub-element specifications on non-structured files.</summary>
/// <param name="input">The PCCC address string to test.</param>
[Theory]
[InlineData("B3:0.DN")] // B (bit) file has no structured elements
[InlineData("N7:0.FOO")] // N (integer) file has no structured elements
@@ -89,6 +106,8 @@ public sealed class AbLegacyAddressTests
public void TryParse_rejects_subelement_on_non_structured_file(string input) =>
AbLegacyAddress.TryParse(input).ShouldBeNull();
/// <summary>Verifies that TryParse accepts sub-element specifications only on T, C, and R files.</summary>
/// <param name="input">The PCCC address string to test.</param>
[Theory]
[InlineData("T4:0.ACC")] // T, C, R are the only structured-element files
[InlineData("C5:0.PRE")]
@@ -96,6 +115,8 @@ public sealed class AbLegacyAddressTests
public void TryParse_accepts_subelement_only_on_TCR_files(string input) =>
AbLegacyAddress.TryParse(input).ShouldNotBeNull();
/// <summary>Verifies that TryParse accepts I, O, and S addresses without file numbers.</summary>
/// <param name="input">The PCCC address string to test.</param>
[Theory]
[InlineData("I:0/0")] // I/O/S without file number are valid
[InlineData("O:1/2")]
@@ -13,6 +13,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
[Trait("Category", "Unit")]
public sealed class AbLegacyBitIndexRangeTests
{
/// <summary>Verifies that bit index 0 to 15 is accepted on 16-bit files.</summary>
/// <param name="input">The PCCC address string to test.</param>
[Theory]
[InlineData("N7:0/15")]
[InlineData("B3:0/15")]
@@ -23,6 +25,8 @@ public sealed class AbLegacyBitIndexRangeTests
public void Bit_index_0_to_15_accepted_on_16bit_files(string input) =>
AbLegacyAddress.TryParse(input).ShouldNotBeNull();
/// <summary>Verifies that bit index above 15 is rejected on 16-bit files.</summary>
/// <param name="input">The PCCC address string to test.</param>
[Theory]
[InlineData("N7:0/16")] // first bit past a 16-bit word
[InlineData("N7:0/20")]
@@ -35,6 +39,8 @@ public sealed class AbLegacyBitIndexRangeTests
public void Bit_index_above_15_rejected_on_16bit_files(string input) =>
AbLegacyAddress.TryParse(input).ShouldBeNull();
/// <summary>Verifies that bit index 0 to 31 is accepted on L file.</summary>
/// <param name="input">The PCCC address string to test.</param>
[Theory]
[InlineData("L9:0/0")]
[InlineData("L9:0/15")]
@@ -43,20 +49,25 @@ public sealed class AbLegacyBitIndexRangeTests
public void Bit_index_0_to_31_accepted_on_L_file(string input) =>
AbLegacyAddress.TryParse(input).ShouldNotBeNull();
/// <summary>Verifies that bit index above 31 is rejected on L file.</summary>
[Fact]
public void Bit_index_above_31_rejected_on_L_file() =>
AbLegacyAddress.TryParse("L9:0/32").ShouldBeNull();
/// <summary>Verifies that bit index is rejected on float file.</summary>
/// <param name="input">The PCCC address string to test.</param>
[Theory]
[InlineData("F8:0/0")] // float files are not bit-addressable at all
[InlineData("F8:0/3")]
public void Bit_index_rejected_on_float_file(string input) =>
AbLegacyAddress.TryParse(input).ShouldBeNull();
/// <summary>Verifies that negative bit index is still rejected.</summary>
[Fact]
public void Negative_bit_index_still_rejected() =>
AbLegacyAddress.TryParse("N7:0/-1").ShouldBeNull();
/// <summary>Verifies that bit in word RMW against L file uses 32-bit parent and high bit.</summary>
[Fact]
public async Task Bit_in_word_RMW_against_L_file_uses_32bit_parent_and_high_bit()
{
@@ -81,6 +92,7 @@ public sealed class AbLegacyBitIndexRangeTests
Convert.ToInt32(factory.Tags["L9:0"].Value).ShouldBe(1 << 20);
}
/// <summary>Verifies that bit in word RMW high bit 15 does not corrupt via sign extension.</summary>
[Fact]
public async Task Bit_in_word_RMW_high_bit_15_does_not_corrupt_via_sign_extension()
{
@@ -8,6 +8,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
[Trait("Category", "Unit")]
public sealed class AbLegacyBitRmwTests
{
/// <summary>Verifies that setting a bit reads the parent word, ORs the bit, and writes back.</summary>
[Fact]
public async Task Bit_set_reads_parent_word_ORs_bit_writes_back()
{
@@ -31,6 +32,7 @@ public sealed class AbLegacyBitRmwTests
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0b1001);
}
/// <summary>Verifies that clearing a bit preserves other bits in the word.</summary>
[Fact]
public async Task Bit_clear_preserves_other_bits_in_N_file_word()
{
@@ -51,6 +53,7 @@ public sealed class AbLegacyBitRmwTests
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(unchecked((short)0xFFF7));
}
/// <summary>Verifies that concurrent bit writes to the same word compose correctly.</summary>
[Fact]
public async Task Concurrent_bit_writes_to_same_word_compose_correctly()
{
@@ -75,6 +78,7 @@ public sealed class AbLegacyBitRmwTests
Convert.ToInt32(factory.Tags["N7:0"].Value).ShouldBe(0xFF);
}
/// <summary>Verifies that repeated bit writes reuse the parent word runtime.</summary>
[Fact]
public async Task Repeat_bit_writes_reuse_parent_runtime()
{
@@ -11,6 +11,7 @@ public sealed class AbLegacyCapabilityTests
{
// ---- ITagDiscovery ----
/// <summary>Verifies that DiscoverAsync emits pre-declared tags under the device folder.</summary>
[Fact]
public async Task DiscoverAsync_emits_pre_declared_tags_under_device_folder()
{
@@ -38,6 +39,7 @@ public sealed class AbLegacyCapabilityTests
// ---- ISubscribable ----
/// <summary>Verifies that Subscribe initial poll raises OnDataChange.</summary>
[Fact]
public async Task Subscribe_initial_poll_raises_OnDataChange()
{
@@ -63,6 +65,7 @@ public sealed class AbLegacyCapabilityTests
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
/// <summary>Verifies that Unsubscribe halts polling.</summary>
[Fact]
public async Task Unsubscribe_halts_polling()
{
@@ -92,6 +95,7 @@ public sealed class AbLegacyCapabilityTests
// ---- IHostConnectivityProbe ----
/// <summary>Verifies that GetHostStatuses returns one status per device.</summary>
[Fact]
public async Task GetHostStatuses_returns_one_per_device()
{
@@ -109,6 +113,7 @@ public sealed class AbLegacyCapabilityTests
drv.GetHostStatuses().Count.ShouldBe(2);
}
/// <summary>Verifies that Probe transitions to Running on successful read.</summary>
[Fact]
public async Task Probe_transitions_to_Running_on_successful_read()
{
@@ -132,6 +137,7 @@ public sealed class AbLegacyCapabilityTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that Probe transitions to Stopped on read failure.</summary>
[Fact]
public async Task Probe_transitions_to_Stopped_on_read_failure()
{
@@ -155,6 +161,7 @@ public sealed class AbLegacyCapabilityTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that Probe is disabled when ProbeAddress is null.</summary>
[Fact]
public async Task Probe_disabled_when_ProbeAddress_is_null()
{
@@ -172,6 +179,7 @@ public sealed class AbLegacyCapabilityTests
// ---- IPerCallHostResolver ----
/// <summary>Verifies that ResolveHost returns declared device for known tag.</summary>
[Fact]
public async Task ResolveHost_returns_declared_device_for_known_tag()
{
@@ -195,6 +203,7 @@ public sealed class AbLegacyCapabilityTests
drv.ResolveHost("B").ShouldBe("ab://10.0.0.6/1,0");
}
/// <summary>Verifies that ResolveHost falls back to first device for unknown tags.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
{
@@ -208,6 +217,7 @@ public sealed class AbLegacyCapabilityTests
drv.ResolveHost("missing").ShouldBe("ab://10.0.0.5/1,0");
}
/// <summary>Verifies that ResolveHost falls back to DriverInstanceId when no devices exist.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
{
@@ -228,22 +238,43 @@ public sealed class AbLegacyCapabilityTests
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets list of folders created during discovery.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets list of variables created during discovery.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Records folder creation.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Records variable creation.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="info">The driver attribute information.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>Records property addition (stub implementation).</summary>
/// <param name="_">The property name (unused).</param>
/// <param name="__">The data type (unused).</param>
/// <param name="___">The property value (unused).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference of the variable.</summary>
public string FullReference => fullRef;
/// <summary>Marks the variable as an alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
/// <summary>Null sink for alarm condition transitions.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <inheritdoc />
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -17,6 +17,7 @@ public sealed class AbLegacyDisposeAndResolveHostTests
{
// ---- Driver.AbLegacy-011 ----
/// <summary>Verifies that Dispose performs teardown without blocking on async operations.</summary>
[Fact]
public async Task Dispose_runs_teardown_without_blocking_on_async_wait()
{
@@ -45,6 +46,7 @@ public sealed class AbLegacyDisposeAndResolveHostTests
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Verifies that Dispose can be called multiple times without throwing.</summary>
[Fact]
public async Task Dispose_is_idempotent()
{
@@ -58,6 +60,7 @@ public sealed class AbLegacyDisposeAndResolveHostTests
Should.NotThrow(() => drv.Dispose());
}
/// <summary>Verifies that Dispose does not deadlock under a single-threaded synchronization context.</summary>
[Fact]
public async Task Dispose_under_single_threaded_sync_context_does_not_deadlock()
{
@@ -100,9 +103,14 @@ public sealed class AbLegacyDisposeAndResolveHostTests
{
private readonly System.Collections.Concurrent.BlockingCollection<(SendOrPostCallback, object?)> _queue = new();
/// <inheritdoc />
public override void Post(SendOrPostCallback d, object? state) => _queue.Add((d, state));
/// <inheritdoc />
public override void Send(SendOrPostCallback d, object? state) => d(state);
/// <summary>Runs the event loop until the stop signal is set.</summary>
/// <param name="stop">The event to signal loop completion.</param>
public void RunUntil(ManualResetEventSlim stop)
{
while (!stop.IsSet)
@@ -114,11 +122,13 @@ public sealed class AbLegacyDisposeAndResolveHostTests
}
}
/// <summary>Disposes the internal queue.</summary>
public void Dispose() => _queue.Dispose();
}
// ---- Driver.AbLegacy-013 ----
/// <summary>Verifies that ResolveHost returns the configured device for a known tag reference.</summary>
[Fact]
public void ResolveHost_known_reference_returns_tag_device()
{
@@ -130,6 +140,7 @@ public sealed class AbLegacyDisposeAndResolveHostTests
drv.ResolveHost("X").ShouldBe("ab://10.0.0.5/1,0");
}
/// <summary>Verifies that ResolveHost returns the first configured device when reference is unknown.</summary>
[Fact]
public void ResolveHost_unknown_reference_with_devices_returns_first_device()
{
@@ -146,6 +157,7 @@ public sealed class AbLegacyDisposeAndResolveHostTests
drv.ResolveHost("unknown").ShouldBe("ab://10.0.0.5/1,0");
}
/// <summary>Verifies that ResolveHost returns the driver instance ID when no devices are configured.</summary>
[Fact]
public void ResolveHost_unknown_reference_no_devices_returns_driver_instance_id()
{
@@ -9,6 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
[Trait("Category", "Unit")]
public sealed class AbLegacyDriverTests
{
/// <summary>Verifies that driver type is AbLegacy.</summary>
[Fact]
public void DriverType_is_AbLegacy()
{
@@ -17,6 +18,7 @@ public sealed class AbLegacyDriverTests
drv.DriverInstanceId.ShouldBe("drv-1");
}
/// <summary>Verifies that InitializeAsync with devices assigns family profiles.</summary>
[Fact]
public async Task InitializeAsync_with_devices_assigns_family_profiles()
{
@@ -38,6 +40,7 @@ public sealed class AbLegacyDriverTests
drv.GetDeviceState("ab://10.0.0.7/1,0")!.Profile.ShouldBe(AbLegacyPlcFamilyProfile.Plc5);
}
/// <summary>Verifies that InitializeAsync with malformed host address faults.</summary>
[Fact]
public async Task InitializeAsync_with_malformed_host_address_faults()
{
@@ -51,6 +54,7 @@ public sealed class AbLegacyDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>Verifies that ShutdownAsync clears devices.</summary>
[Fact]
public async Task ShutdownAsync_clears_devices()
{
@@ -65,6 +69,7 @@ public sealed class AbLegacyDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
/// <summary>Verifies that family profiles expose expected defaults.</summary>
[Fact]
public void Family_profiles_expose_expected_defaults()
{
@@ -82,6 +87,9 @@ public sealed class AbLegacyDriverTests
AbLegacyPlcFamilyProfile.LogixPccc.SupportsLongFile.ShouldBeTrue();
}
/// <summary>Verifies that ForFamily dispatches correctly.</summary>
/// <param name="family">The PLC family to dispatch for.</param>
/// <param name="expectedAttribute">The expected libplctag PLC attribute.</param>
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500, "slc500")]
[InlineData(AbLegacyPlcFamily.MicroLogix, "micrologix")]
@@ -92,6 +100,7 @@ public sealed class AbLegacyDriverTests
AbLegacyPlcFamilyProfile.ForFamily(family).LibplctagPlcAttribute.ShouldBe(expectedAttribute);
}
/// <summary>Verifies that data type mapping covers atomic PCCC types.</summary>
[Fact]
public void DataType_mapping_covers_atomic_pccc_types()
{
@@ -105,6 +114,7 @@ public sealed class AbLegacyDriverTests
// ---- Driver.AbLegacy-012: profile fields consumed ----
/// <summary>Verifies that EffectiveCipPath falls back to profile default when host path is empty.</summary>
[Fact]
public async Task EffectiveCipPath_falls_back_to_profile_default_when_host_path_is_empty()
{
@@ -124,6 +134,7 @@ public sealed class AbLegacyDriverTests
factory.Tags["N7:0"].CreationParams.CipPath.ShouldBe("1,0");
}
/// <summary>Verifies that EffectiveCipPath preserves explicit host path.</summary>
[Fact]
public async Task EffectiveCipPath_preserves_explicit_host_path()
{
@@ -142,6 +153,7 @@ public sealed class AbLegacyDriverTests
factory.Tags["N7:0"].CreationParams.CipPath.ShouldBe("1,2");
}
/// <summary>Verifies that long tag on MicroLogix device is rejected at initialization.</summary>
[Fact]
public async Task Long_tag_on_MicroLogix_device_rejected_at_init()
{
@@ -157,6 +169,7 @@ public sealed class AbLegacyDriverTests
ex.Message.ShouldContain("L-files");
}
/// <summary>Verifies that long tag on SLC 500 device is accepted.</summary>
[Fact]
public async Task Long_tag_on_Slc500_device_accepted()
{
@@ -172,6 +185,7 @@ public sealed class AbLegacyDriverTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// <summary>Verifies that string tag on PLC-5 device is rejected at initialization.</summary>
[Fact]
public async Task String_tag_on_Plc5_device_rejected_at_init()
{
@@ -8,6 +8,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
[Trait("Category", "Unit")]
public sealed class AbLegacyHostAndStatusTests
{
/// <summary>Verifies that HostAddress parses valid URI strings correctly.</summary>
/// <param name="input">The raw URI string to parse.</param>
/// <param name="gateway">The expected gateway host.</param>
/// <param name="port">The expected port number.</param>
/// <param name="path">The expected CIP path component.</param>
[Theory]
[InlineData("ab://10.0.0.5/1,0", "10.0.0.5", 44818, "1,0")]
[InlineData("ab://10.0.0.5/", "10.0.0.5", 44818, "")]
@@ -22,6 +27,8 @@ public sealed class AbLegacyHostAndStatusTests
parsed.CipPath.ShouldBe(path);
}
/// <summary>Verifies that HostAddress rejects invalid URI strings.</summary>
/// <param name="input">The invalid or null URI string to test.</param>
[Theory]
[InlineData(null)]
[InlineData("http://10.0.0.5/1,0")]
@@ -33,6 +40,7 @@ public sealed class AbLegacyHostAndStatusTests
AbLegacyHostAddress.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies that HostAddress.ToString produces canonical URI format.</summary>
[Fact]
public void HostAddress_ToString_canonicalises()
{
@@ -40,6 +48,9 @@ public sealed class AbLegacyHostAndStatusTests
new AbLegacyHostAddress("10.0.0.5", 2222, "1,0").ToString().ShouldBe("ab://10.0.0.5:2222/1,0");
}
/// <summary>Verifies that PCCC status codes are mapped to OPC UA status codes correctly.</summary>
/// <param name="sts">The PCCC status byte to map.</param>
/// <param name="expected">The expected OPC UA status code.</param>
[Theory]
[InlineData((byte)0x00, AbLegacyStatusMapper.Good)]
[InlineData((byte)0x10, AbLegacyStatusMapper.BadNotSupported)]
@@ -54,6 +65,9 @@ public sealed class AbLegacyHostAndStatusTests
AbLegacyStatusMapper.MapPcccStatus(sts).ShouldBe(expected);
}
/// <summary>Verifies that libplctag Status enum members are mapped to OPC UA status codes correctly.</summary>
/// <param name="status">The libplctag Status enum value to map.</param>
/// <param name="expected">The expected OPC UA status code.</param>
// Driver.AbLegacy-010 — tests use the libplctag.NET Status enum members (what
// (int)Tag.GetStatus() actually returns) rather than the unverified magic integers
// that predated this fix (-5/-7/-14/-16/-17 matched neither native PLCTAG_ERR_*
@@ -78,6 +92,7 @@ public sealed class AbLegacyHostAndStatusTests
AbLegacyStatusMapper.MapLibplctagStatus((int)status).ShouldBe(expected);
}
/// <summary>Verifies that timeout errors are distinguished from generic communication errors.</summary>
[Fact]
public void MapLibplctagStatus_distinguishes_timeout_from_generic_comms_error()
{
@@ -18,18 +18,27 @@ public sealed class AbLegacyLoggerInjectionTests
private sealed class CapturingLogger : ILogger<AbLegacyDriver>
{
public readonly List<(LogLevel Level, string Message)> Entries = new();
/// <inheritdoc />
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
private sealed class NullScope : IDisposable
{
public static readonly NullScope Instance = new();
/// <inheritdoc />
public void Dispose() { }
}
}
/// <summary>Verifies that the driver accepts an optional logger parameter.</summary>
[Fact]
public void Driver_accepts_optional_logger_parameter()
{
@@ -40,6 +49,7 @@ public sealed class AbLegacyLoggerInjectionTests
drv.ShouldNotBeNull();
}
/// <summary>Verifies that driver initialization failure emits an error log.</summary>
[Fact]
public async Task InitializeAsync_failure_emits_error_log()
{
@@ -57,6 +67,7 @@ public sealed class AbLegacyLoggerInjectionTests
errors[0].Message.ShouldContain("drv-logged");
}
/// <summary>Verifies that the first non-zero libplctag status per device is logged.</summary>
[Fact]
public async Task First_nonzero_libplctag_status_per_device_is_logged()
{
@@ -22,6 +22,7 @@ public sealed class AbLegacyReadWriteTests
// ---- Read ----
/// <summary>Verifies that an unknown reference maps to BadNodeIdUnknown.</summary>
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
@@ -32,6 +33,7 @@ public sealed class AbLegacyReadWriteTests
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that a successful N-file read returns a Good status.</summary>
[Fact]
public async Task Successful_N_file_read_returns_Good_value()
{
@@ -48,6 +50,7 @@ public sealed class AbLegacyReadWriteTests
factory.Tags["N7:0"].ReadCount.ShouldBe(1);
}
/// <summary>Verifies that repeated reads reuse the runtime.</summary>
[Fact]
public async Task Repeat_read_reuses_runtime()
{
@@ -63,6 +66,7 @@ public sealed class AbLegacyReadWriteTests
factory.Tags["N7:0"].ReadCount.ShouldBe(2);
}
/// <summary>Verifies that non-zero libplctag status values map via AbLegacyStatusMapper.</summary>
[Fact]
public async Task NonZero_libplctag_status_maps_via_AbLegacyStatusMapper()
{
@@ -77,6 +81,7 @@ public sealed class AbLegacyReadWriteTests
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that read exceptions surface as BadCommunicationError.</summary>
[Fact]
public async Task Read_exception_surfaces_BadCommunicationError()
{
@@ -90,6 +95,7 @@ public sealed class AbLegacyReadWriteTests
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>Verifies that batched reads preserve order.</summary>
[Fact]
public async Task Batched_reads_preserve_order()
{
@@ -113,6 +119,7 @@ public sealed class AbLegacyReadWriteTests
snapshots[2].Value.ShouldBe("hello");
}
/// <summary>Verifies that read tag creation parameters are composed from device and profile.</summary>
[Fact]
public async Task Read_TagCreateParams_composed_from_device_and_profile()
{
@@ -132,6 +139,7 @@ public sealed class AbLegacyReadWriteTests
// ---- Write ----
/// <summary>Verifies that a non-writable tag rejects with BadNotWritable.</summary>
[Fact]
public async Task Non_writable_tag_rejects_with_BadNotWritable()
{
@@ -144,6 +152,7 @@ public sealed class AbLegacyReadWriteTests
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
}
/// <summary>Verifies that a successful N-file write encodes and flushes the data.</summary>
[Fact]
public async Task Successful_N_file_write_encodes_and_flushes()
{
@@ -159,6 +168,7 @@ public sealed class AbLegacyReadWriteTests
factory.Tags["N7:0"].WriteCount.ShouldBe(1);
}
/// <summary>Verifies that bit-within-word write now succeeds via RMW.</summary>
[Fact]
public async Task Bit_within_word_write_now_succeeds_via_RMW()
{
@@ -179,6 +189,7 @@ public sealed class AbLegacyReadWriteTests
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
}
/// <summary>Verifies that write exceptions surface as BadCommunicationError.</summary>
[Fact]
public async Task Write_exception_surfaces_BadCommunicationError()
{
@@ -192,6 +203,7 @@ public sealed class AbLegacyReadWriteTests
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
}
/// <summary>Verifies that batch write preserves order across different outcomes.</summary>
[Fact]
public async Task Batch_write_preserves_order_across_outcomes()
{
@@ -220,6 +232,7 @@ public sealed class AbLegacyReadWriteTests
results[2].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that cancellation propagates through the driver.</summary>
[Fact]
public async Task Cancellation_propagates()
{
@@ -236,6 +249,7 @@ public sealed class AbLegacyReadWriteTests
() => drv.ReadAsync(["X"], CancellationToken.None));
}
/// <summary>Verifies that ShutdownAsync disposes all runtimes.</summary>
[Fact]
public async Task ShutdownAsync_disposes_runtimes()
{
@@ -252,6 +266,11 @@ public sealed class AbLegacyReadWriteTests
private sealed class RmwThrowingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
{
/// <summary>Encodes a value for the tag, throwing for unsupported bit-within-word RMW operations.</summary>
/// <param name="type">The data type of the tag.</param>
/// <param name="bitIndex">Optional bit index within a word.</param>
/// <param name="value">The value to encode.</param>
/// <inheritdoc />
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
{
if (type == AbLegacyDataType.Bit && bitIndex is not null)
@@ -22,10 +22,17 @@ public sealed class AbLegacyRuntimeConcurrencyTests
private sealed class OverlapDetectingFake : FakeAbLegacyTag
{
private int _inFlight;
/// <summary>Gets the maximum number of concurrent operations detected.</summary>
public int MaxConcurrent { get; private set; }
/// <summary>Initializes a new instance of the OverlapDetectingFake class.</summary>
/// <param name="p">The tag creation parameters.</param>
public OverlapDetectingFake(AbLegacyTagCreateParams p) : base(p) { }
/// <summary>Reads the tag asynchronously while tracking concurrent operations.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the read operation.</returns>
public override async Task ReadAsync(CancellationToken ct)
{
EnterOp();
@@ -38,6 +45,9 @@ public sealed class AbLegacyRuntimeConcurrencyTests
finally { LeaveOp(); }
}
/// <summary>Writes to the tag asynchronously while tracking concurrent operations.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the write operation.</returns>
public override async Task WriteAsync(CancellationToken ct)
{
EnterOp();
@@ -58,6 +68,7 @@ public sealed class AbLegacyRuntimeConcurrencyTests
private void LeaveOp() => Interlocked.Decrement(ref _inFlight);
}
/// <summary>Verifies that concurrent reads of the same tag are serialised against the shared runtime.</summary>
[Fact]
public async Task Concurrent_reads_of_same_tag_are_serialised_against_the_shared_runtime()
{
@@ -90,6 +101,7 @@ public sealed class AbLegacyRuntimeConcurrencyTests
reads.ShouldAllBe(r => r.Result.Single().Value!.Equals(7));
}
/// <summary>Verifies that concurrent read and write operations on the same tag do not overlap.</summary>
[Fact]
public async Task Concurrent_read_and_write_of_same_tag_do_not_overlap()
{
@@ -4,20 +4,46 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
internal class FakeAbLegacyTag : IAbLegacyTagRuntime
{
/// <summary>Gets the tag creation parameters.</summary>
public AbLegacyTagCreateParams CreationParams { get; }
/// <summary>Gets or sets the tag value.</summary>
public object? Value { get; set; }
/// <summary>Gets or sets the tag status code.</summary>
public int Status { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on initialization.</summary>
public bool ThrowOnInitialize { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on read.</summary>
public bool ThrowOnRead { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on write.</summary>
public bool ThrowOnWrite { get; set; }
/// <summary>Gets or sets the exception to throw.</summary>
public Exception? Exception { get; set; }
/// <summary>Gets the count of initialization calls.</summary>
public int InitializeCount { get; private set; }
/// <summary>Gets the count of read calls.</summary>
public int ReadCount { get; private set; }
/// <summary>Gets the count of write calls.</summary>
public int WriteCount { get; private set; }
/// <summary>Gets a value indicating whether the tag has been disposed.</summary>
public bool Disposed { get; private set; }
/// <summary>Initializes a new instance of the FakeAbLegacyTag class.</summary>
/// <param name="p">The tag creation parameters.</param>
public FakeAbLegacyTag(AbLegacyTagCreateParams p) => CreationParams = p;
/// <summary>Initializes the tag asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public virtual Task InitializeAsync(CancellationToken ct)
{
InitializeCount++;
@@ -25,6 +51,9 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
return Task.CompletedTask;
}
/// <summary>Reads the tag value asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public virtual Task ReadAsync(CancellationToken ct)
{
ReadCount++;
@@ -32,6 +61,9 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
return Task.CompletedTask;
}
/// <summary>Writes the tag value asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public virtual Task WriteAsync(CancellationToken ct)
{
WriteCount++;
@@ -39,17 +71,38 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
return Task.CompletedTask;
}
/// <summary>Gets the current tag status.</summary>
/// <returns>The status code.</returns>
public virtual int GetStatus() => Status;
/// <summary>Decodes the tag value based on the specified data type and bit index.</summary>
/// <param name="type">The AbLegacy data type.</param>
/// <param name="bitIndex">The bit index if applicable.</param>
/// <returns>The decoded value.</returns>
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
/// <summary>Encodes the tag value based on the specified data type and bit index.</summary>
/// <param name="type">The AbLegacy data type.</param>
/// <param name="bitIndex">The bit index if applicable.</param>
/// <param name="value">The value to encode.</param>
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
/// <summary>Disposes the tag.</summary>
public virtual void Dispose() => Disposed = true;
}
/// <summary>Test factory for creating fake AbLegacy tags.</summary>
internal sealed class FakeAbLegacyTagFactory : IAbLegacyTagFactory
{
/// <summary>Gets the collection of created tags, keyed by tag name.</summary>
public Dictionary<string, FakeAbLegacyTag> Tags { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets or sets an optional customization function for tag creation.</summary>
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag>? Customise { get; set; }
/// <summary>Creates a new AbLegacy tag with the specified parameters.</summary>
/// <param name="p">The tag creation parameters.</param>
/// <returns>The created tag.</returns>
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p)
{
var fake = Customise?.Invoke(p) ?? new FakeAbLegacyTag(p);
@@ -23,7 +23,10 @@ public sealed class FocasSimFixture : IAsyncDisposable
private const string DefaultHost = "localhost";
private const int DefaultPort = 8193;
/// <summary>Gets the hostname or IP address of the focas-mock simulator.</summary>
public string Host { get; }
/// <summary>Gets the TCP port of the focas-mock simulator.</summary>
public int Port { get; }
/// <summary>focas-mock profile stem the fixture should load (e.g. <c>fwlib30i64</c>,
@@ -37,6 +40,10 @@ public sealed class FocasSimFixture : IAsyncDisposable
/// <summary>Non-null when the mock probe failed — tests skip with this reason.</summary>
public string? SkipReason { get; }
/// <summary>
/// Initializes a new instance of the <see cref="FocasSimFixture"/> class by probing the focas-mock
/// simulator endpoint. Reads the endpoint and profile from environment variables or uses defaults.
/// </summary>
public FocasSimFixture()
{
var endpoint = Environment.GetEnvironmentVariable(EndpointEnvVar) ?? $"{DefaultHost}:{DefaultPort}";
@@ -66,6 +73,7 @@ public sealed class FocasSimFixture : IAsyncDisposable
}
}
/// <summary>Disposes the fixture and releases any held resources.</summary>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
// ---- Admin API helpers ----
@@ -75,23 +83,33 @@ public sealed class FocasSimFixture : IAsyncDisposable
/// (<c>fwlib30i64</c>) or the OtOpcUa-style alias (<c>ThirtyOne_i</c>);
/// focas-mock's <c>PROFILE_ALIASES</c> resolves both.
/// </summary>
/// <param name="profileName">The DLL-stem name or OtOpcUa-style alias of the profile to load.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<JsonElement> LoadProfileAsync(string profileName, CancellationToken ct = default) =>
SendAdminAsync("mock_load_profile", new { profile = profileName }, ct);
/// <summary>Deep-merge <paramref name="state"/> into the mock's current state.</summary>
/// <param name="state">The state object to deep-merge into the mock's current state.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<JsonElement> PatchStateAsync(object state, CancellationToken ct = default) =>
SendAdminAsync("mock_patch", new { state }, ct);
/// <summary>Reset the mock to the selected profile's default state.</summary>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<JsonElement> ResetAsync(CancellationToken ct = default) =>
SendAdminAsync("mock_reset", new { }, ct);
/// <summary>Install a time-scheduled alarm raise / clear sequence.</summary>
/// <param name="sequence">The alarm sequence events to schedule.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public Task<JsonElement> ScheduleAlarmsAsync(IEnumerable<object> sequence, CancellationToken ct = default) =>
SendAdminAsync("mock_schedule_alarms", new { sequence }, ct);
/// <summary>Low-level JSON round-trip. One TCP connection per call — matches
/// how the shim talks to the mock; simpler than pooling.</summary>
/// <param name="method">The admin method name to invoke.</param>
/// <param name="params">The parameters object to send with the request.</param>
/// <param name="ct">Cancellation token for the operation.</param>
public async Task<JsonElement> SendAdminAsync(string method, object @params, CancellationToken ct = default)
{
using var client = new TcpClient();
@@ -19,10 +19,13 @@ public sealed class WireBackendCoverageTests
{
private readonly FocasSimFixture _fx;
/// <summary>Initializes a new instance of WireBackendCoverageTests with the FOCAS simulation fixture.</summary>
/// <param name="fx">The FOCAS simulation fixture.</param>
public WireBackendCoverageTests(FocasSimFixture fx) => _fx = fx;
private const string DeviceHost = "focas://127.0.0.1:8193";
/// <summary>Verifies that user tag reads route via the wire backend.</summary>
[Fact]
public async Task User_tag_reads_route_via_wire_backend()
{
@@ -69,6 +72,7 @@ public sealed class WireBackendCoverageTests
}
}
/// <summary>Verifies that discover emits device folder and tag variables.</summary>
[Fact]
public async Task Discover_emits_device_folder_and_tag_variables()
{
@@ -102,6 +106,7 @@ public sealed class WireBackendCoverageTests
}
}
/// <summary>Verifies that subscribe fires OnDataChange via the wire backend.</summary>
[Fact]
public async Task Subscribe_fires_OnDataChange_via_wire_backend()
{
@@ -151,6 +156,7 @@ public sealed class WireBackendCoverageTests
}
}
/// <summary>Verifies that alarm raise then clear emits both events via the wire backend.</summary>
[Fact]
public async Task Alarm_raise_then_clear_emits_both_events_via_wire_backend()
{
@@ -203,6 +209,7 @@ public sealed class WireBackendCoverageTests
}
}
/// <summary>Verifies that the probe transitions to Running against the live mock.</summary>
[Fact]
public async Task Probe_transitions_to_Running_against_live_mock()
{
@@ -242,22 +249,46 @@ public sealed class WireBackendCoverageTests
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of recorded folders.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the list of recorded variables.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Records a folder in the address space builder.</summary>
/// <param name="browseName">The browse name for the folder.</param>
/// <param name="displayName">The display name for the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Records a variable in the address space builder.</summary>
/// <param name="browseName">The browse name for the variable.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="info">The driver attribute information.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>Records an address space property (no-op in this builder).</summary>
/// <param name="_">The property name.</param>
/// <param name="__">The property data type.</param>
/// <param name="___">The property value.</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full OPC UA reference for the variable.</summary>
public string FullReference => fullRef;
/// <summary>Marks the variable as an alarm condition and returns a sink.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
private sealed class NullSink : IAlarmConditionSink
{
/// <summary>Handles an alarm transition event (no-op in this sink).</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -20,10 +20,15 @@ public sealed class WireBackendTests
{
private readonly FocasSimFixture _fx;
/// <summary>
/// Initializes a new instance of the <see cref="WireBackendTests"/> class.
/// </summary>
/// <param name="fx">The FOCAS simulator fixture.</param>
public WireBackendTests(FocasSimFixture fx) => _fx = fx;
private const string DeviceHost = "focas://127.0.0.1:8193";
/// <summary>Verifies that identity axes and dynamic data populate via the wire backend.</summary>
[Fact]
public async Task Identity_axes_and_dynamic_populate_via_wire_backend()
{
@@ -80,6 +85,7 @@ public sealed class WireBackendTests
}
}
/// <summary>Verifies that program and operation mode data populate via the wire backend.</summary>
[Fact]
public async Task Program_and_operation_mode_populate_via_wire_backend()
{
@@ -144,6 +150,7 @@ public sealed class WireBackendTests
}
}
/// <summary>Verifies that timer data populates via the wire backend.</summary>
[Fact]
public async Task Timers_populate_via_wire_backend()
{
@@ -201,6 +208,7 @@ public sealed class WireBackendTests
}
}
/// <summary>Verifies that spindle load and max RPM data populate via the wire backend.</summary>
[Fact]
public async Task Spindle_load_and_max_rpm_populate_via_wire_backend()
{
@@ -4,20 +4,36 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
internal class FakeFocasClient : IFocasClient
{
/// <summary>Gets a value indicating whether the client is connected.</summary>
public bool IsConnected { get; private set; }
/// <summary>Gets the count of connection attempts.</summary>
public int ConnectCount { get; private set; }
/// <summary>Gets the count of dispose operations.</summary>
public int DisposeCount { get; private set; }
/// <summary>Gets or sets a value indicating whether to throw on connect.</summary>
public bool ThrowOnConnect { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on read.</summary>
public bool ThrowOnRead { get; set; }
/// <summary>Gets or sets a value indicating whether to throw on write.</summary>
public bool ThrowOnWrite { get; set; }
/// <summary>Gets or sets the result of probe operations.</summary>
public bool ProbeResult { get; set; } = true;
/// <summary>Gets or sets the exception to throw.</summary>
public Exception? Exception { get; set; }
/// <summary>Gets the dictionary of read values keyed by address.</summary>
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the dictionary of read statuses keyed by address.</summary>
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the dictionary of write statuses keyed by address.</summary>
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Gets the log of write operations.</summary>
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
/// <summary>Connects to a FOCAS host asynchronously.</summary>
/// <param name="address">The FOCAS host address.</param>
/// <param name="timeout">The connection timeout duration.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
{
ConnectCount++;
@@ -26,6 +42,10 @@ internal class FakeFocasClient : IFocasClient
return Task.CompletedTask;
}
/// <summary>Reads a value from a FOCAS address asynchronously.</summary>
/// <param name="address">The FOCAS address to read from.</param>
/// <param name="type">The data type of the value.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
@@ -36,6 +56,11 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult((value, status));
}
/// <summary>Writes a value to a FOCAS address asynchronously.</summary>
/// <param name="address">The FOCAS address to write to.</param>
/// <param name="type">The data type of the value.</param>
/// <param name="value">The value to write.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
@@ -46,24 +71,42 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult(status);
}
/// <summary>Probes the FOCAS connection asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
/// <summary>Gets the list of active alarms.</summary>
public List<FocasActiveAlarm> Alarms { get; } = [];
/// <summary>Reads active alarms asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
// ---- Fixed-tree T1 ----
/// <summary>Gets or sets the system information.</summary>
public FocasSysInfo SysInfo { get; set; } = new(0, 3, "M", "M", "30i", "A1.0", 3);
/// <summary>Gets the list of axis names.</summary>
public List<FocasAxisName> AxisNames { get; } = [new("X", ""), new("Y", ""), new("Z", "")];
/// <summary>Gets the list of spindle names.</summary>
public List<FocasSpindleName> SpindleNames { get; } = [new("S", "1", "", "")];
/// <summary>Gets the dictionary of dynamic snapshots keyed by axis index.</summary>
public Dictionary<int, FocasDynamicSnapshot> DynamicByAxis { get; } = [];
/// <summary>Gets system information asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => Task.FromResult(SysInfo);
/// <summary>Gets axis names asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasAxisName>>([.. AxisNames]);
/// <summary>Gets spindle names asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasSpindleName>>([.. SpindleNames]);
/// <summary>Reads dynamic data for an axis asynchronously.</summary>
/// <param name="axisIndex">The zero-based axis index.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
{
if (!DynamicByAxis.TryGetValue(axisIndex, out var snap))
@@ -71,11 +114,18 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult(snap);
}
/// <summary>Gets or sets the program information.</summary>
public FocasProgramInfo ProgramInfo { get; set; } = new("O0001", 1, 0, 1);
/// <summary>Gets program information asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
Task.FromResult(ProgramInfo);
/// <summary>Gets the dictionary of timers keyed by timer kind.</summary>
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
/// <summary>Gets timer data asynchronously.</summary>
/// <param name="kind">The timer kind to retrieve.</param>
/// <param name="ct">The cancellation token.</param>
public virtual Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken ct)
{
if (!Timers.TryGetValue(kind, out var t))
@@ -83,17 +133,27 @@ internal class FakeFocasClient : IFocasClient
return Task.FromResult(t);
}
/// <summary>Gets the list of servo loads.</summary>
public List<FocasServoLoad> ServoLoads { get; } = [];
/// <summary>Gets servo loads asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
/// <summary>Gets the list of spindle loads.</summary>
public List<int> SpindleLoads { get; } = [];
/// <summary>Gets the list of spindle maximum RPMs.</summary>
public List<int> SpindleMaxRpms { get; } = [];
/// <summary>Gets spindle loads asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
/// <summary>Gets spindle maximum RPMs asynchronously.</summary>
/// <param name="ct">The cancellation token.</param>
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
/// <summary>Disposes the client.</summary>
public virtual void Dispose()
{
DisposeCount++;
@@ -101,11 +161,15 @@ internal class FakeFocasClient : IFocasClient
}
}
/// <summary>A factory for creating fake FOCAS clients.</summary>
internal sealed class FakeFocasClientFactory : IFocasClientFactory
{
/// <summary>Gets the list of created clients.</summary>
public List<FakeFocasClient> Clients { get; } = new();
/// <summary>Gets or sets a customization function for creating clients.</summary>
public Func<FakeFocasClient>? Customise { get; set; }
/// <summary>Creates a fake FOCAS client.</summary>
public IFocasClient Create()
{
var c = Customise?.Invoke() ?? new FakeFocasClient();
@@ -26,6 +26,7 @@ public sealed class FocasAlarmProjectionTests
return (drv, factory);
}
/// <summary>Verifies that subscribe without enable throws NotSupported.</summary>
[Fact]
public async Task Subscribe_without_Enable_throws_NotSupported()
{
@@ -36,6 +37,7 @@ public sealed class FocasAlarmProjectionTests
drv.SubscribeAlarmsAsync([], CancellationToken.None));
}
/// <summary>Verifies that raise then clear emits both events.</summary>
[Fact]
public async Task Raise_then_clear_emits_both_events()
{
@@ -67,6 +69,7 @@ public sealed class FocasAlarmProjectionTests
events[0].SourceNodeId.ShouldBe(Host);
}
/// <summary>Verifies that tick diffs raises and clears without polling loop.</summary>
[Fact]
public async Task Tick_diffs_raises_and_clears_without_polling_loop()
{
@@ -111,6 +114,7 @@ public sealed class FocasAlarmProjectionTests
events[0].AlarmType.ShouldBe("Parameter");
}
/// <summary>Verifies that severity mapping matches docs.</summary>
[Fact]
public void Severity_mapping_matches_docs()
{
@@ -16,6 +16,10 @@ public sealed class FocasCapabilityMatrixTests
{
// ---- Macro ranges ----
/// <summary>Verifies that macro range matches series.</summary>
/// <param name="series">The FOCAS CNC series to validate.</param>
/// <param name="number">The macro number to test.</param>
/// <param name="accepted">Whether the address should be accepted.</param>
[Theory]
[InlineData(FocasCncSeries.Sixteen_i, 999, true)]
[InlineData(FocasCncSeries.Sixteen_i, 1000, false)] // above legacy ceiling
@@ -37,6 +41,10 @@ public sealed class FocasCapabilityMatrixTests
// ---- Parameter ranges ----
/// <summary>Verifies that parameter range matches series.</summary>
/// <param name="series">The FOCAS CNC series to validate.</param>
/// <param name="number">The parameter number to test.</param>
/// <param name="accepted">Whether the address should be accepted.</param>
[Theory]
[InlineData(FocasCncSeries.Sixteen_i, 9999, true)]
[InlineData(FocasCncSeries.Sixteen_i, 10000, false)] // 16i capped at 9999
@@ -53,6 +61,10 @@ public sealed class FocasCapabilityMatrixTests
// ---- PMC letters ----
/// <summary>Verifies that PMC letter matches series.</summary>
/// <param name="series">The FOCAS CNC series to validate.</param>
/// <param name="letter">The PMC signal group letter to test.</param>
/// <param name="accepted">Whether the address should be accepted.</param>
[Theory]
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
@@ -77,6 +89,11 @@ public sealed class FocasCapabilityMatrixTests
// ---- PMC number ceiling ----
/// <summary>Verifies that PMC number ceiling matches series.</summary>
/// <param name="series">The FOCAS CNC series to validate.</param>
/// <param name="letter">The PMC signal group letter to test.</param>
/// <param name="number">The PMC address number to test.</param>
/// <param name="accepted">Whether the address should be accepted.</param>
[Theory]
[InlineData(FocasCncSeries.Sixteen_i, "R", 999, true)]
[InlineData(FocasCncSeries.Sixteen_i, "R", 1000, false)]
@@ -95,6 +112,9 @@ public sealed class FocasCapabilityMatrixTests
// ---- Unknown series is permissive ----
/// <summary>Verifies that unknown series accepts any PMC.</summary>
/// <param name="letter">The PMC signal group letter to test.</param>
/// <param name="number">The PMC address number to test.</param>
[Theory]
[InlineData("Z", 999_999)] // absurd PMC address
[InlineData("Q", 0)] // non-existent letter
@@ -104,6 +124,7 @@ public sealed class FocasCapabilityMatrixTests
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
}
/// <summary>Verifies that unknown series accepts any macro number.</summary>
[Fact]
public void Unknown_series_accepts_any_macro_number()
{
@@ -111,6 +132,7 @@ public sealed class FocasCapabilityMatrixTests
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
}
/// <summary>Verifies that unknown series accepts any parameter number.</summary>
[Fact]
public void Unknown_series_accepts_any_parameter_number()
{
@@ -120,6 +142,7 @@ public sealed class FocasCapabilityMatrixTests
// ---- Reason messages include enough context to diagnose ----
/// <summary>Verifies that rejection message names series and limit.</summary>
[Fact]
public void Rejection_message_names_series_and_limit()
{
@@ -131,6 +154,7 @@ public sealed class FocasCapabilityMatrixTests
reason.ShouldContain("9999");
}
/// <summary>Verifies that PMC rejection lists accepted letters.</summary>
[Fact]
public void Pmc_rejection_lists_accepted_letters()
{
@@ -144,6 +168,8 @@ public sealed class FocasCapabilityMatrixTests
// ---- PMC address letter is case-insensitive ----
/// <summary>Verifies that PMC letter match is case insensitive on 30i.</summary>
/// <param name="letter">The PMC signal group letter to test in various cases.</param>
[Theory]
[InlineData("x")]
[InlineData("X")]
@@ -11,6 +11,7 @@ public sealed class FocasCapabilityTests
{
// ---- ITagDiscovery ----
/// <summary>Verifies that DiscoverAsync emits pre-declared tags.</summary>
[Fact]
public async Task DiscoverAsync_emits_pre_declared_tags()
{
@@ -39,6 +40,7 @@ public sealed class FocasCapabilityTests
// ---- ISubscribable ----
/// <summary>Verifies that the initial subscription poll raises an OnDataChange event.</summary>
[Fact]
public async Task Subscribe_initial_poll_raises_OnDataChange()
{
@@ -64,6 +66,7 @@ public sealed class FocasCapabilityTests
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
/// <summary>Verifies that ShutdownAsync cancels active subscriptions.</summary>
[Fact]
public async Task ShutdownAsync_cancels_active_subscriptions()
{
@@ -93,6 +96,7 @@ public sealed class FocasCapabilityTests
// ---- IHostConnectivityProbe ----
/// <summary>Verifies that GetHostStatuses returns one entry per device.</summary>
[Fact]
public async Task GetHostStatuses_returns_entry_per_device()
{
@@ -110,6 +114,7 @@ public sealed class FocasCapabilityTests
drv.GetHostStatuses().Count.ShouldBe(2);
}
/// <summary>Verifies that the probe transitions to Running on successful connection.</summary>
[Fact]
public async Task Probe_transitions_to_Running_on_success()
{
@@ -136,6 +141,7 @@ public sealed class FocasCapabilityTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that the probe transitions to Stopped on connection failure.</summary>
[Fact]
public async Task Probe_transitions_to_Stopped_on_failure()
{
@@ -164,6 +170,7 @@ public sealed class FocasCapabilityTests
// ---- IPerCallHostResolver ----
/// <summary>Verifies that ResolveHost returns the declared device for a known tag.</summary>
[Fact]
public async Task ResolveHost_returns_declared_device_for_known_tag()
{
@@ -187,6 +194,7 @@ public sealed class FocasCapabilityTests
drv.ResolveHost("B").ShouldBe("focas://10.0.0.6:8193");
}
/// <summary>Verifies that ResolveHost falls back to the first device for unknown tags.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
{
@@ -200,6 +208,7 @@ public sealed class FocasCapabilityTests
drv.ResolveHost("missing").ShouldBe("focas://10.0.0.5:8193");
}
/// <summary>Verifies that ResolveHost falls back to the driver instance ID when no devices are configured.</summary>
[Fact]
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
{
@@ -218,24 +227,49 @@ public sealed class FocasCapabilityTests
await Task.Delay(20);
}
/// <summary>Test double that records IAddressSpaceBuilder calls.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of recorded folder calls.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Gets the list of recorded variable calls.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Records a folder call.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
/// <returns>This builder for chaining.</returns>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Records a variable call.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="info">The driver attribute information.</param>
/// <returns>A variable handle for the recorded variable.</returns>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>Records a property call (no-op).</summary>
/// <param name="_">The property name (unused).</param>
/// <param name="__">The property data type (unused).</param>
/// <param name="___">The property value (unused).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference.</summary>
public string FullReference => fullRef;
/// <summary>Marks as alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
/// <returns>An alarm condition sink.</returns>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
/// <summary>Null alarm condition sink.</summary>
private sealed class NullSink : IAlarmConditionSink {
/// <summary>Handles transition (no-op).</summary>
/// <param name="args">The alarm event arguments (unused).</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -20,6 +20,7 @@ public sealed class FocasDriverMediumFindingsTests
{
// ---- Driver.FOCAS-003: unknown DeviceHostAddress fails at InitializeAsync ----
/// <summary>Verifies that initialization throws when a tag references an undeclared device.</summary>
[Fact]
public async Task InitializeAsync_throws_when_tag_DeviceHostAddress_not_in_Devices()
{
@@ -41,6 +42,7 @@ public sealed class FocasDriverMediumFindingsTests
ex.Message.ShouldContain("not in the Devices list");
}
/// <summary>Verifies that initialization errors name the offending tag.</summary>
[Fact]
public async Task InitializeAsync_throws_naming_the_offending_tag()
{
@@ -61,6 +63,7 @@ public sealed class FocasDriverMediumFindingsTests
ex.Message.ShouldContain("TypoTag");
}
/// <summary>Verifies that initialization succeeds when all tags reference declared devices.</summary>
[Fact]
public async Task InitializeAsync_succeeds_when_all_tags_reference_declared_devices()
{
@@ -86,6 +89,7 @@ public sealed class FocasDriverMediumFindingsTests
// ---- Driver.FOCAS-004: all FOCAS user tags advertised as ViewOnly ----
/// <summary>Verifies that all user tags are advertised as ViewOnly regardless of Writable setting.</summary>
[Fact]
public async Task DiscoverAsync_all_user_tags_are_ViewOnly_regardless_of_Writable_field()
{
@@ -114,6 +118,7 @@ public sealed class FocasDriverMediumFindingsTests
// ---- Driver.FOCAS-005: Volatile-guarded _health survives concurrent reads ----
/// <summary>Verifies that GetHealth reflects state updated from concurrent reads.</summary>
[Fact]
public async Task GetHealth_reflects_state_updated_from_concurrent_reads()
{
@@ -142,6 +147,7 @@ public sealed class FocasDriverMediumFindingsTests
// ---- Driver.FOCAS-006: EnsureConnectedAsync recreates a disposed/stale client ----
/// <summary>Verifies that reads recover after client is externally disposed.</summary>
[Fact]
public async Task Read_recovers_after_client_is_externally_disposed()
{
@@ -177,6 +183,7 @@ public sealed class FocasDriverMediumFindingsTests
factory.Clients[1].ConnectCount.ShouldBe(1);
}
/// <summary>Verifies that reads dispose stale clients before creating fresh ones.</summary>
[Fact]
public async Task Read_disposes_stale_client_before_creating_fresh_one()
{
@@ -211,6 +218,7 @@ public sealed class FocasDriverMediumFindingsTests
// ---- Driver.FOCAS-012: factory round-trip for all three opt-in sections ----
/// <summary>Verifies factory round-trip with all optional configuration sections.</summary>
[Fact]
public void CreateInstance_full_round_trip_all_opt_in_sections()
{
@@ -240,24 +248,51 @@ public sealed class FocasDriverMediumFindingsTests
// ---- helpers ----
/// <summary>Records folder and variable additions for test verification.</summary>
private sealed class RecordingBuilder : IAddressSpaceBuilder
{
/// <summary>Gets or sets the list of added variables.</summary>
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
/// <summary>Gets or sets the list of added folders.</summary>
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
/// <summary>Records a folder and returns this builder for chaining.</summary>
/// <param name="browseName">The OPC UA browse name for the folder.</param>
/// <param name="displayName">The display name for the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{ Folders.Add((browseName, displayName)); return this; }
/// <summary>Records a variable and returns a handle for it.</summary>
/// <param name="browseName">The OPC UA browse name for the variable.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="info">The driver attribute information for the variable.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
/// <summary>No-op property addition for test compatibility.</summary>
/// <param name="_">The property name (unused).</param>
/// <param name="__">The property data type (unused).</param>
/// <param name="___">The property value (unused).</param>
public void AddProperty(string _, DriverDataType __, object? ___) { }
/// <summary>Test variable handle implementation.</summary>
private sealed class Handle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference path of this variable.</summary>
public string FullReference => fullRef;
/// <summary>Marks this variable as an alarm condition and returns a sink for it.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
}
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
/// <summary>No-op alarm condition sink for testing.</summary>
private sealed class NullSink : IAlarmConditionSink
{
/// <summary>Handles alarm condition transitions (no-op for testing).</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
@@ -14,6 +14,7 @@ public sealed class FocasFactoryConfigTests
{
// ---- Driver.FOCAS-001: FixedTree / AlarmProjection / HandleRecycle config sections ----
/// <summary>Verifies that the FixedTree configuration section is mapped to driver options.</summary>
[Fact]
public void CreateInstance_maps_FixedTree_section_onto_options()
{
@@ -37,6 +38,7 @@ public sealed class FocasFactoryConfigTests
drv.Options.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(30));
}
/// <summary>Verifies that the AlarmProjection configuration section is mapped to driver options.</summary>
[Fact]
public void CreateInstance_maps_AlarmProjection_section_onto_options()
{
@@ -53,6 +55,7 @@ public sealed class FocasFactoryConfigTests
drv.Options.AlarmProjection.PollInterval.ShouldBe(TimeSpan.FromSeconds(2));
}
/// <summary>Verifies that the HandleRecycle configuration section is mapped to driver options.</summary>
[Fact]
public void CreateInstance_maps_HandleRecycle_section_onto_options()
{
@@ -69,6 +72,7 @@ public sealed class FocasFactoryConfigTests
drv.Options.HandleRecycle.Interval.ShouldBe(TimeSpan.FromHours(1));
}
/// <summary>Verifies that all three optional configuration sections are mapped together.</summary>
[Fact]
public void CreateInstance_round_trips_all_three_opt_in_sections_together()
{
@@ -88,6 +92,7 @@ public sealed class FocasFactoryConfigTests
drv.Options.HandleRecycle.Enabled.ShouldBeTrue();
}
/// <summary>Verifies that default disabled values are maintained when sections are absent.</summary>
[Fact]
public void CreateInstance_keeps_disabled_defaults_when_sections_absent()
{
@@ -98,6 +103,7 @@ public sealed class FocasFactoryConfigTests
drv.Options.HandleRecycle.Enabled.ShouldBeFalse();
}
/// <summary>Verifies that per-field defaults are maintained when only Enabled is supplied.</summary>
[Fact]
public void CreateInstance_keeps_per_field_defaults_when_only_Enabled_supplied()
{
@@ -112,6 +118,7 @@ public sealed class FocasFactoryConfigTests
// ---- Driver.FOCAS-002: fixed-tree bootstrap must not declare a false ProgramInfo capability ----
/// <summary>Verifies that ProgramInfo is marked unsupported when probe throws.</summary>
[Fact]
public async Task FixedTree_bootstrap_marks_ProgramInfo_unsupported_when_probe_throws()
{
@@ -138,6 +145,7 @@ public sealed class FocasFactoryConfigTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that ProgramInfo is marked supported when probe succeeds.</summary>
[Fact]
public async Task FixedTree_bootstrap_marks_ProgramInfo_supported_when_probe_succeeds()
{
@@ -175,6 +183,10 @@ public sealed class FocasFactoryConfigTests
/// </summary>
private sealed class ProgramInfoFailingFocasClient : FakeFocasClient
{
/// <summary>Gets program info by throwing to simulate unsupported operations.</summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>Never returns; always throws.</returns>
/// <inheritdoc />
public override Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
throw new InvalidOperationException(
"cnc_exeprgname2 failed EW_6 and cnc_rdopmode failed EW_6.");
@@ -6,6 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasHandleRecycleTests
{
/// <summary>Verifies that the recycle loop disposes clients on interval and reopens fresh ones.</summary>
[Fact]
public async Task Recycle_loop_disposes_client_on_interval_reads_reopen_fresh_one()
{
@@ -38,6 +39,7 @@ public sealed class FocasHandleRecycleTests
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>Verifies that the recycle loop stays off when not enabled.</summary>
[Fact]
public async Task Recycle_loop_stays_off_when_not_enabled()
{
@@ -13,6 +13,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
[Trait("Category", "Unit")]
public sealed class FocasLoggingTests
{
/// <summary>Verifies that the constructor accepts an ILogger.</summary>
[Fact]
public void Constructor_accepts_an_ILogger()
{
@@ -29,6 +30,7 @@ public sealed class FocasLoggingTests
drv.ShouldNotBeNull();
}
/// <summary>Verifies that probe loop logs when an exception is swallowed.</summary>
[Fact]
public async Task ProbeLoop_logs_when_an_exception_is_swallowed()
{
@@ -82,10 +84,23 @@ public sealed class FocasLoggingTests
private sealed class CapturingLogger<T> : ILogger<T>
{
/// <summary>Gets the captured log entries.</summary>
public List<(LogLevel Level, string Message, Exception? Exception)> Entries { get; } = new();
/// <summary>Begins a logging scope.</summary>
/// <param name="state">The scope state.</param>
/// <typeparam name="TState">The type of the state.</typeparam>
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
/// <summary>Checks if logging is enabled for the specified level.</summary>
/// <param name="logLevel">The log level.</param>
public bool IsEnabled(LogLevel logLevel) => true;
/// <summary>Logs a message.</summary>
/// <param name="logLevel">The log level.</param>
/// <param name="eventId">The event ID.</param>
/// <param name="state">The state object.</param>
/// <param name="exception">The exception, if any.</param>
/// <param name="formatter">The formatter function.</param>
/// <typeparam name="TState">The type of the state.</typeparam>
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter)
{
@@ -94,7 +109,9 @@ public sealed class FocasLoggingTests
private sealed class NullScope : IDisposable
{
/// <summary>Gets the singleton instance.</summary>
public static NullScope Instance { get; } = new();
/// <summary>Disposes the scope.</summary>
public void Dispose() { }
}
}
@@ -20,6 +20,9 @@ public sealed class FocasLowFindingsTests
{
// ---- Driver.FOCAS-008 — parsed FocasAddress cached at init ----
/// <summary>
/// Verifies that ReadAsync uses cached FocasAddress when tag definition has a malformed address after init.
/// </summary>
[Fact]
public async Task ReadAsync_uses_cached_FocasAddress_when_tag_definition_has_a_malformed_address_after_init()
{
@@ -53,6 +56,9 @@ public sealed class FocasLowFindingsTests
.ShouldBeTrue("ReadAsync must reuse the FocasAddress parsed at init, not re-parse per read");
}
/// <summary>
/// Verifies that WriteAsync also uses cached FocasAddress.
/// </summary>
[Fact]
public async Task WriteAsync_uses_cached_FocasAddress_too()
{
@@ -90,6 +96,9 @@ public sealed class FocasLowFindingsTests
// ---- Driver.FOCAS-009 — Probe.Timeout applies to ProbeAsync ----
/// <summary>
/// Verifies that ProbeLoop cancels a slow ProbeAsync at Probe Timeout.
/// </summary>
[Fact]
public async Task ProbeLoop_cancels_a_slow_ProbeAsync_at_Probe_Timeout()
{
@@ -130,6 +139,11 @@ public sealed class FocasLowFindingsTests
// ---- Driver.FOCAS-010 — operation-mode → text mapping is consolidated ----
/// <summary>
/// Verifies that OpMode ToText yields the same label in both namespaces.
/// </summary>
/// <param name="code">The operation mode code to test.</param>
/// <param name="expected">The expected text representation.</param>
[Theory]
[InlineData(0, "MDI")]
[InlineData(1, "AUTO")]
@@ -149,6 +163,9 @@ public sealed class FocasLowFindingsTests
((FocasOperationMode)(short)code).ToText().ShouldBe(expected);
}
/// <summary>
/// Verifies that OpMode ToText fallback label is consistent.
/// </summary>
[Fact]
public void OpMode_ToText_fallback_label_is_consistent()
{
@@ -162,6 +179,9 @@ public sealed class FocasLowFindingsTests
// ---- Driver.FOCAS-011 — FocasAlarmType constants typed as short ----
/// <summary>
/// Verifies that FocasAlarmType constants are typed as short.
/// </summary>
[Fact]
public void FocasAlarmType_constants_are_typed_short()
{
@@ -184,6 +204,7 @@ public sealed class FocasLowFindingsTests
private sealed class CapturingFakeFocasClient(List<FocasAddress> captured) : FakeFocasClient
{
/// <inheritdoc />
public override Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
@@ -191,6 +212,7 @@ public sealed class FocasLowFindingsTests
return base.ReadAsync(address, type, ct);
}
/// <inheritdoc />
public override Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
@@ -201,6 +223,7 @@ public sealed class FocasLowFindingsTests
private sealed class HangingProbeFakeClient(TaskCompletionSource cancelledSignal) : FakeFocasClient
{
/// <inheritdoc />
public override async Task<bool> ProbeAsync(CancellationToken ct)
{
try
@@ -15,8 +15,10 @@ public sealed class FocasPmcBitRmwTests
/// </summary>
private sealed class PmcRmwFake : FakeFocasClient
{
/// <summary>Gets the simulated PMC byte storage.</summary>
public byte[] PmcBytes { get; } = new byte[1024];
/// <inheritdoc />
public override Task<(object? value, uint status)> ReadAsync(
FocasAddress address, FocasDataType type, CancellationToken ct)
{
@@ -27,6 +29,7 @@ public sealed class FocasPmcBitRmwTests
return base.ReadAsync(address, type, ct);
}
/// <inheritdoc />
public override Task<uint> WriteAsync(
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
{
@@ -62,6 +65,7 @@ public sealed class FocasPmcBitRmwTests
return (drv, fake);
}
/// <summary>Verifies that a bit set operation surfaces as Good status and flips the bit.</summary>
[Fact]
public async Task Bit_set_surfaces_as_Good_status_and_flips_bit()
{
@@ -76,6 +80,7 @@ public sealed class FocasPmcBitRmwTests
fake.PmcBytes[100].ShouldBe((byte)0b0000_1001);
}
/// <summary>Verifies that clearing a bit preserves other bits.</summary>
[Fact]
public async Task Bit_clear_preserves_other_bits()
{
@@ -89,6 +94,7 @@ public sealed class FocasPmcBitRmwTests
fake.PmcBytes[100].ShouldBe((byte)0b1111_0111);
}
/// <summary>Verifies that subsequent bit sets in the same byte compose correctly.</summary>
[Fact]
public async Task Subsequent_bit_sets_in_same_byte_compose_correctly()
{
@@ -105,6 +111,7 @@ public sealed class FocasPmcBitRmwTests
fake.PmcBytes[100].ShouldBe((byte)0xFF);
}
/// <summary>Verifies that bit writes to different bytes do not contend.</summary>
[Fact]
public async Task Bit_write_to_different_bytes_does_not_contend()
{
@@ -22,6 +22,7 @@ public sealed class FocasReadWriteTests
// ---- Read ----
/// <summary>Verifies that an unknown reference maps to BadNodeIdUnknown.</summary>
[Fact]
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
{
@@ -32,6 +33,7 @@ public sealed class FocasReadWriteTests
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that a successful PMC read returns a Good status value.</summary>
[Fact]
public async Task Successful_PMC_read_returns_Good_value()
{
@@ -45,6 +47,7 @@ public sealed class FocasReadWriteTests
snapshots.Single().Value.ShouldBe((sbyte)5);
}
/// <summary>Verifies that parameter reads route through the FocasAddress Parameter kind.</summary>
[Fact]
public async Task Parameter_read_routes_through_FocasAddress_Parameter_kind()
{
@@ -58,6 +61,7 @@ public sealed class FocasReadWriteTests
snapshots.Single().Value.ShouldBe(1500);
}
/// <summary>Verifies that macro reads route through the FocasAddress Macro kind.</summary>
[Fact]
public async Task Macro_read_routes_through_FocasAddress_Macro_kind()
{
@@ -70,6 +74,7 @@ public sealed class FocasReadWriteTests
snapshots.Single().Value.ShouldBe(3.14159);
}
/// <summary>Verifies that repeated reads reuse the connection.</summary>
[Fact]
public async Task Repeat_read_reuses_connection()
{
@@ -85,6 +90,7 @@ public sealed class FocasReadWriteTests
factory.Clients[0].ConnectCount.ShouldBe(1);
}
/// <summary>Verifies that FOCAS error statuses map correctly via the status mapper.</summary>
[Fact]
public async Task FOCAS_error_status_maps_via_status_mapper()
{
@@ -102,6 +108,7 @@ public sealed class FocasReadWriteTests
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that a read exception surfaces BadCommunicationError.</summary>
[Fact]
public async Task Read_exception_surfaces_BadCommunicationError()
{
@@ -115,6 +122,7 @@ public sealed class FocasReadWriteTests
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
}
/// <summary>Verifies that a connection failure disposes the client and surfaces BadCommunicationError.</summary>
[Fact]
public async Task Connect_failure_disposes_client_and_surfaces_BadCommunicationError()
{
@@ -128,6 +136,7 @@ public sealed class FocasReadWriteTests
factory.Clients[0].DisposeCount.ShouldBe(1);
}
/// <summary>Verifies that batched reads preserve order across different address areas.</summary>
[Fact]
public async Task Batched_reads_preserve_order_across_areas()
{
@@ -154,6 +163,7 @@ public sealed class FocasReadWriteTests
// ---- Write ----
/// <summary>Verifies that a non-writable tag write is rejected with BadNotWritable.</summary>
[Fact]
public async Task Non_writable_tag_rejected_with_BadNotWritable()
{
@@ -166,6 +176,7 @@ public sealed class FocasReadWriteTests
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
/// <summary>Verifies that a successful write logs the address, type, and value.</summary>
[Fact]
public async Task Successful_write_logs_address_type_value()
{
@@ -183,6 +194,7 @@ public sealed class FocasReadWriteTests
write.value.ShouldBe((short)1800);
}
/// <summary>Verifies that write status codes map correctly via the FocasStatusMapper.</summary>
[Fact]
public async Task Write_status_code_maps_via_FocasStatusMapper()
{
@@ -201,6 +213,7 @@ public sealed class FocasReadWriteTests
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
}
/// <summary>Verifies that batched writes preserve order across different outcomes.</summary>
[Fact]
public async Task Batch_write_preserves_order_across_outcomes()
{
@@ -229,6 +242,7 @@ public sealed class FocasReadWriteTests
results[2].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
}
/// <summary>Verifies that cancellation signals are propagated.</summary>
[Fact]
public async Task Cancellation_propagates()
{
@@ -245,6 +259,7 @@ public sealed class FocasReadWriteTests
() => drv.ReadAsync(["X"], CancellationToken.None));
}
/// <summary>Verifies that ShutdownAsync disposes the client.</summary>
[Fact]
public async Task ShutdownAsync_disposes_client()
{
@@ -10,6 +10,10 @@ public sealed class FocasScaffoldingTests
{
// ---- FocasHostAddress ----
/// <summary>Verifies FocasHostAddress.TryParse correctly parses valid addresses.</summary>
/// <param name="input">The input address string to parse.</param>
/// <param name="host">The expected host after parsing.</param>
/// <param name="port">The expected port after parsing.</param>
[Theory]
[InlineData("focas://10.0.0.5:8193", "10.0.0.5", 8193)]
[InlineData("focas://10.0.0.5", "10.0.0.5", 8193)] // default port
@@ -24,6 +28,8 @@ public sealed class FocasScaffoldingTests
parsed.Port.ShouldBe(port);
}
/// <summary>Verifies FocasHostAddress.TryParse rejects invalid addresses.</summary>
/// <param name="input">The input address string to test for rejection.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -38,6 +44,7 @@ public sealed class FocasScaffoldingTests
FocasHostAddress.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies FocasHostAddress.ToString strips the default port.</summary>
[Fact]
public void HostAddress_ToString_strips_default_port()
{
@@ -47,6 +54,12 @@ public sealed class FocasScaffoldingTests
// ---- FocasAddress ----
/// <summary>Verifies FocasAddress.TryParse correctly parses PMC address forms.</summary>
/// <param name="input">The input address string to parse.</param>
/// <param name="kind">The expected FocasAreaKind after parsing.</param>
/// <param name="letter">The expected PMC letter after parsing.</param>
/// <param name="num">The expected number after parsing.</param>
/// <param name="bit">The expected bit index after parsing.</param>
[Theory]
[InlineData("X0.0", FocasAreaKind.Pmc, "X", 0, 0)]
[InlineData("X0", FocasAreaKind.Pmc, "X", 0, null)]
@@ -70,6 +83,11 @@ public sealed class FocasScaffoldingTests
a.BitIndex.ShouldBe(bit);
}
/// <summary>Verifies FocasAddress.TryParse correctly parses parameter address forms.</summary>
/// <param name="input">The input address string to parse.</param>
/// <param name="kind">The expected FocasAreaKind after parsing.</param>
/// <param name="num">The expected number after parsing.</param>
/// <param name="bit">The expected bit index after parsing.</param>
[Theory]
[InlineData("PARAM:1020", FocasAreaKind.Parameter, 1020, null)]
[InlineData("PARAM:1815/0", FocasAreaKind.Parameter, 1815, 0)]
@@ -84,6 +102,10 @@ public sealed class FocasScaffoldingTests
a.BitIndex.ShouldBe(bit);
}
/// <summary>Verifies FocasAddress.TryParse correctly parses macro address forms.</summary>
/// <param name="input">The input address string to parse.</param>
/// <param name="kind">The expected FocasAreaKind after parsing.</param>
/// <param name="num">The expected number after parsing.</param>
[Theory]
[InlineData("MACRO:100", FocasAreaKind.Macro, 100)]
[InlineData("MACRO:500", FocasAreaKind.Macro, 500)]
@@ -96,6 +118,8 @@ public sealed class FocasScaffoldingTests
a.BitIndex.ShouldBeNull();
}
/// <summary>Verifies FocasAddress.TryParse rejects invalid address forms.</summary>
/// <param name="input">The input address string to test for rejection.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
@@ -114,6 +138,8 @@ public sealed class FocasScaffoldingTests
FocasAddress.TryParse(input).ShouldBeNull();
}
/// <summary>Verifies FocasAddress.Canonical roundtrips correctly.</summary>
/// <param name="input">The input address string to roundtrip.</param>
[Theory]
[InlineData("X0.0")]
[InlineData("R100")]
@@ -130,6 +156,7 @@ public sealed class FocasScaffoldingTests
// ---- FocasDataType ----
/// <summary>Verifies data type mapping covers all atomic FOCAS types.</summary>
[Fact]
public void DataType_mapping_covers_atomic_focas_types()
{
@@ -143,6 +170,9 @@ public sealed class FocasScaffoldingTests
// ---- FocasStatusMapper ----
/// <summary>Verifies status mapper covers all known FOCAS return codes.</summary>
/// <param name="ret">The FOCAS return code to map.</param>
/// <param name="expected">The expected mapped status code.</param>
[Theory]
[InlineData(0, FocasStatusMapper.Good)]
[InlineData(3, FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
@@ -161,6 +191,7 @@ public sealed class FocasScaffoldingTests
// ---- FocasDriver ----
/// <summary>Verifies FocasDriver initializes with correct type and ID.</summary>
[Fact]
public void DriverType_is_FOCAS()
{
@@ -169,6 +200,7 @@ public sealed class FocasScaffoldingTests
drv.DriverInstanceId.ShouldBe("drv-1");
}
/// <summary>Verifies InitializeAsync parses device addresses correctly.</summary>
[Fact]
public async Task InitializeAsync_parses_device_addresses()
{
@@ -188,6 +220,7 @@ public sealed class FocasScaffoldingTests
drv.GetDeviceState("focas://10.0.0.6:12345")!.Options.DeviceName.ShouldBe("CNC-2");
}
/// <summary>Verifies InitializeAsync faults on malformed addresses.</summary>
[Fact]
public async Task InitializeAsync_malformed_address_faults()
{
@@ -201,6 +234,7 @@ public sealed class FocasScaffoldingTests
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <summary>Verifies ShutdownAsync clears all devices.</summary>
[Fact]
public async Task ShutdownAsync_clears_devices()
{
@@ -218,6 +252,7 @@ public sealed class FocasScaffoldingTests
// ---- UnimplementedFocasClientFactory ----
/// <summary>Verifies UnimplementedFocasClientFactory throws on Create.</summary>
[Fact]
public void Unimplemented_factory_throws_on_Create_with_config_pointer()
{
@@ -15,6 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;
/// </summary>
public sealed class DataTypeMapTests
{
/// <summary>Verifies that Map maps known codes to expected driver data types.</summary>
/// <param name="mxDataType">The Galaxy mx_data_type integer code.</param>
/// <param name="expected">The expected driver data type.</param>
[Theory]
[InlineData(0, DriverDataType.Boolean)]
[InlineData(1, DriverDataType.Int32)]
@@ -28,6 +31,7 @@ public sealed class DataTypeMapTests
DataTypeMap.Map(mxDataType).ShouldBe(expected);
}
/// <summary>Verifies that Int64 code does not fall through to String default.</summary>
[Fact]
public void Map_Int64Code_DoesNotFallThroughToStringDefault()
{
@@ -36,6 +40,8 @@ public sealed class DataTypeMapTests
DataTypeMap.Map(6).ShouldNotBe(DriverDataType.String);
}
/// <summary>Verifies that unknown code falls back to String.</summary>
/// <param name="mxDataType">An unrecognised Galaxy mx_data_type code.</param>
[Theory]
[InlineData(7)]
[InlineData(99)]
@@ -23,20 +23,46 @@ public sealed class DeployWatcherTests
private sealed class FakeDeployWatchSource : IGalaxyDeployWatchSource
{
private readonly Func<int, Channel<DeployEvent>> _channelFactory;
/// <summary>
/// Gets the list of last-seen deployment times from each watch iteration.
/// </summary>
public List<DateTimeOffset?> LastSeenTimes { get; } = [];
/// <summary>
/// Gets the number of times WatchAsync has been called.
/// </summary>
public int CallCount { get; private set; }
/// <summary>
/// Gets or sets a function that can throw an exception on specific iterations.
/// </summary>
public Func<int, Exception?>? ThrowOnIteration { get; init; }
/// <summary>
/// Initializes a new instance of the <see cref="FakeDeployWatchSource"/> class with a single channel.
/// </summary>
/// <param name="channel">The deploy event channel to use for all iterations.</param>
public FakeDeployWatchSource(Channel<DeployEvent> channel)
{
_channelFactory = _ => channel;
}
/// <summary>
/// Initializes a new instance of the <see cref="FakeDeployWatchSource"/> class with a channel factory.
/// </summary>
/// <param name="channelFactory">A factory function that creates channels for each iteration.</param>
public FakeDeployWatchSource(Func<int, Channel<DeployEvent>> channelFactory)
{
_channelFactory = channelFactory;
}
/// <summary>
/// Watches for deploy events asynchronously.
/// </summary>
/// <param name="lastSeenDeployTime">The last deployment time seen.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async enumerable of deploy events.</returns>
public async IAsyncEnumerable<DeployEvent> WatchAsync(
DateTimeOffset? lastSeenDeployTime,
[EnumeratorCancellation] CancellationToken cancellationToken)
@@ -93,6 +119,7 @@ public sealed class DeployWatcherTests
throw new TimeoutException("Condition was not met within timeout.");
}
/// <summary>Verifies that bootstrap deploy events are suppressed.</summary>
[Fact]
public async Task BootstrapEventIsSuppressed()
{
@@ -115,6 +142,7 @@ public sealed class DeployWatcherTests
await watcher.StopAsync();
}
/// <summary>Verifies that a deployment time change fires a rediscovery event.</summary>
[Fact]
public async Task DeployTimeChangeFiresRediscover()
{
@@ -142,6 +170,7 @@ public sealed class DeployWatcherTests
await watcher.StopAsync();
}
/// <summary>Verifies that the same deployment time does not fire a rediscovery event.</summary>
[Fact]
public async Task SameDeployTimeDoesNotFire()
{
@@ -165,6 +194,7 @@ public sealed class DeployWatcherTests
await watcher.StopAsync();
}
/// <summary>Verifies that a deployment time presence flip fires a rediscovery event.</summary>
[Fact]
public async Task TimeOfLastDeployPresentFlipFiresRediscover()
{
@@ -191,6 +221,7 @@ public sealed class DeployWatcherTests
await watcher.StopAsync();
}
/// <summary>Verifies that stop cancels the watcher loop cleanly.</summary>
[Fact]
public async Task StopCancelsLoopCleanly()
{
@@ -211,6 +242,7 @@ public sealed class DeployWatcherTests
await stopTask; // observe (no) exception
}
/// <summary>Verifies that disposing stops a running watcher.</summary>
[Fact]
public async Task DisposeStopsRunningWatcher()
{
@@ -229,6 +261,7 @@ public sealed class DeployWatcherTests
await disposeTask;
}
/// <summary>Verifies that a source exception triggers retry with backoff.</summary>
[Fact]
public async Task SourceExceptionTriggersRetryWithBackoff()
{
@@ -16,6 +16,9 @@ public sealed class GalaxyDiscovererTests
{
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
{
/// <summary>Gets the hierarchy asynchronously from the fake source.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that returns the pre-built Galaxy object list.</returns>
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
=> Task.FromResult(objects);
}
@@ -25,15 +28,23 @@ public sealed class GalaxyDiscovererTests
private sealed class FakeBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of folder creation calls recorded by this builder.</summary>
public List<FolderCall> Folders { get; } = [];
/// <summary>Gets the list of variable creation calls recorded by this builder.</summary>
public List<VariableCall> Variables { get; } = [];
/// <summary>Gets the dictionary of alarm declarations recorded by this builder.</summary>
public Dictionary<string, AlarmConditionInfo> AlarmDeclarations { get; } = [];
private readonly string? _currentFolder;
/// <summary>Initializes a new instance of the FakeBuilder class at the root level.</summary>
public FakeBuilder() : this(null) { }
private FakeBuilder(string? folder) { _currentFolder = folder; }
/// <summary>Adds a folder call to the recorded list.</summary>
/// <param name="browseName">The browse name for the folder.</param>
/// <param name="displayName">The display name for the folder.</param>
/// <returns>An IAddressSpaceBuilder scoped to the new folder.</returns>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add(new FolderCall(browseName, displayName));
@@ -41,6 +52,11 @@ public sealed class GalaxyDiscovererTests
return new ChildBuilder(this, browseName);
}
/// <summary>Adds a variable call to the recorded list.</summary>
/// <param name="browseName">The browse name for the variable.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="attributeInfo">The attribute metadata for the variable.</param>
/// <returns>An IVariableHandle for further configuration.</returns>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
var folder = _currentFolder ?? "<root>";
@@ -48,30 +64,51 @@ public sealed class GalaxyDiscovererTests
return new FakeVariableHandle(this, attributeInfo.FullName);
}
/// <summary>Adds a property call to the builder (not recorded in this fake).</summary>
/// <param name="browseName">The browse name for the property.</param>
/// <param name="dataType">The driver data type of the property.</param>
/// <param name="value">The property value.</param>
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
// Child folder routes Variable calls back to the parent's lists with its own scope.
/// <summary>Child folder routes Variable calls back to the parent's lists with its own scope.</summary>
private sealed class ChildBuilder(FakeBuilder parent, string folderBrowseName) : IAddressSpaceBuilder
{
/// <summary>Adds a child folder call to the parent builder's recorded list.</summary>
/// <param name="browseName">The browse name for the folder.</param>
/// <param name="displayName">The display name for the folder.</param>
/// <returns>An IAddressSpaceBuilder scoped to the new child folder.</returns>
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
parent.Folders.Add(new FolderCall(browseName, displayName));
return new ChildBuilder(parent, browseName);
}
/// <summary>Adds a variable call to the parent builder's recorded list, scoped to this folder.</summary>
/// <param name="browseName">The browse name for the variable.</param>
/// <param name="displayName">The display name for the variable.</param>
/// <param name="attributeInfo">The attribute metadata for the variable.</param>
/// <returns>An IVariableHandle for further configuration.</returns>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
parent.Variables.Add(new VariableCall(folderBrowseName, browseName, attributeInfo));
return new FakeVariableHandle(parent, attributeInfo.FullName);
}
/// <summary>Adds a property call to the builder (not recorded in this fake).</summary>
/// <param name="browseName">The browse name for the property.</param>
/// <param name="dataType">The driver data type of the property.</param>
/// <param name="value">The property value.</param>
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
}
private sealed class FakeVariableHandle(FakeBuilder owner, string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference for this variable.</summary>
public string FullReference { get; } = fullRef;
/// <summary>Marks this variable as an alarm condition and records it.</summary>
/// <param name="info">The alarm condition metadata.</param>
/// <returns>An IAlarmConditionSink for further alarm configuration.</returns>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
{
owner.AlarmDeclarations[FullReference] = info;
@@ -81,6 +118,8 @@ public sealed class GalaxyDiscovererTests
private sealed class NoopSink : IAlarmConditionSink
{
/// <summary>Records an alarm transition event (no-op in this fake).</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
@@ -116,6 +155,7 @@ public sealed class GalaxyDiscovererTests
return o;
}
/// <summary>Verifies that discovery creates one folder per object and one variable per attribute.</summary>
[Fact]
public async Task DiscoverAsync_BuildsOneFolderPerObject_AndOneVariablePerAttribute()
{
@@ -137,6 +177,7 @@ public sealed class GalaxyDiscovererTests
builder.Variables.ShouldContain(v => v.FolderBrowseName == "Pump" && v.AttributeName == "Running");
}
/// <summary>Verifies that full reference defaults to tag.attribute format when not explicitly supplied.</summary>
[Fact]
public async Task DiscoverAsync_FullReference_DefaultsToTagDotAttribute()
{
@@ -151,6 +192,7 @@ public sealed class GalaxyDiscovererTests
builder.Variables[0].Info.FullName.ShouldBe("Tank1_Level.PV");
}
/// <summary>Verifies that full reference uses gateway-supplied value when provided.</summary>
[Fact]
public async Task DiscoverAsync_FullReference_PrefersGwSuppliedFullTagReference()
{
@@ -165,6 +207,7 @@ public sealed class GalaxyDiscovererTests
builder.Variables[0].Info.FullName.ShouldBe("explicit.full.ref");
}
/// <summary>Verifies that browse name falls back to tag name when contained name is empty.</summary>
[Fact]
public async Task DiscoverAsync_BrowseName_FallsBackToTagName_WhenContainedEmpty()
{
@@ -179,6 +222,7 @@ public sealed class GalaxyDiscovererTests
builder.Folders[0].BrowseName.ShouldBe("Tank1_Level");
}
/// <summary>Verifies that attribute metadata fields are all propagated to the discovered variable.</summary>
[Fact]
public async Task DiscoverAsync_AttributeMetadata_PropagatesEveryField()
{
@@ -203,6 +247,7 @@ public sealed class GalaxyDiscovererTests
info.IsAlarm.ShouldBeFalse();
}
/// <summary>Verifies that alarm attributes populate all five sub-attribute references.</summary>
[Fact]
public async Task DiscoverAsync_AlarmAttribute_PopulatesAllFiveSubAttributeRefs()
{
@@ -224,6 +269,7 @@ public sealed class GalaxyDiscovererTests
info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg");
}
/// <summary>Verifies that non-alarm attributes are not marked as alarm conditions.</summary>
[Fact]
public async Task DiscoverAsync_NonAlarmAttribute_DoesNotMarkCondition()
{
@@ -240,6 +286,7 @@ public sealed class GalaxyDiscovererTests
builder.AlarmDeclarations.ShouldNotContainKey("T.PV");
}
/// <summary>Verifies that objects with empty identity are skipped during discovery.</summary>
[Fact]
public async Task DiscoverAsync_SkipsObjectsWithEmptyIdentity()
{
@@ -256,6 +303,7 @@ public sealed class GalaxyDiscovererTests
builder.Folders[0].BrowseName.ShouldBe("Real");
}
/// <summary>Verifies that attributes with empty names are skipped during discovery.</summary>
[Fact]
public async Task DiscoverAsync_SkipsAttributesWithEmptyName()
{
@@ -271,6 +319,7 @@ public sealed class GalaxyDiscovererTests
builder.Variables[0].AttributeName.ShouldBe("PV");
}
/// <summary>Verifies that driver discovery routes through the injected hierarchy source.</summary>
[Fact]
public async Task DriverDiscoverAsync_RoutesThroughInjectedSource()
{
@@ -287,6 +336,9 @@ public sealed class GalaxyDiscovererTests
/// <summary>Helper that exercises the internal ctor (test seam) without exposing it publicly.</summary>
private sealed class GalaxyDriverHelper
{
/// <summary>Creates a GalaxyDriver with a fake hierarchy source for testing.</summary>
/// <param name="source">The fake hierarchy source to inject.</param>
/// <returns>A GalaxyDriver configured with the fake source.</returns>
public GalaxyDriver CreateWithFakeSource(IGalaxyHierarchySource source)
=> new GalaxyDriver(
"galaxy-test",
@@ -14,6 +14,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// </summary>
public sealed class GalaxyDriverAlarmEventArgsExtensionTests
{
/// <summary>
/// Verifies that acknowledge transition with full payload populates extended fields.
/// </summary>
[Fact]
public async Task Acknowledge_transition_with_full_payload_populates_extended_fields()
{
@@ -47,6 +50,9 @@ public sealed class GalaxyDriverAlarmEventArgsExtensionTests
observed[0].AlarmCategory.ShouldBe("Process");
}
/// <summary>
/// Verifies that raise transition without optional fields leaves them null.
/// </summary>
[Fact]
public async Task Raise_transition_without_optional_fields_leaves_them_null()
{
@@ -95,13 +101,26 @@ public sealed class GalaxyDriverAlarmEventArgsExtensionTests
/// <summary>In-memory <see cref="IGalaxyAlarmFeed"/> the test drives directly.</summary>
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
{
/// <summary>
/// Occurs when an alarm transition is raised.
/// </summary>
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
/// <summary>
/// Starts the alarm feed.
/// </summary>
public void Start() { }
/// <summary>
/// Emits an alarm transition to subscribers.
/// </summary>
/// <param name="transition">The alarm transition to emit.</param>
public void Emit(GalaxyAlarmTransition transition)
=> OnAlarmTransition?.Invoke(this, transition);
/// <summary>
/// Disposes the alarm feed asynchronously.
/// </summary>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}
@@ -16,6 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// </summary>
public sealed class GalaxyDriverAlarmSourceTests
{
/// <summary>Verifies that SubscribeAlarmsAsync starts the alarm feed and events fire on transition.</summary>
[Fact]
public async Task SubscribeAlarmsAsync_starts_feed_and_event_fires_on_transition()
{
@@ -41,6 +42,7 @@ public sealed class GalaxyDriverAlarmSourceTests
observed[0].SubscriptionHandle.ShouldBe(handle);
}
/// <summary>Verifies that OnAlarmEvent does not fire before any alarm subscription.</summary>
[Fact]
public void OnAlarmEvent_does_not_fire_before_any_alarm_subscription()
{
@@ -59,6 +61,7 @@ public sealed class GalaxyDriverAlarmSourceTests
observed.ShouldBeEmpty();
}
/// <summary>Verifies that UnsubscribeAlarmsAsync stops event flow.</summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_stops_event_flow()
{
@@ -80,6 +83,7 @@ public sealed class GalaxyDriverAlarmSourceTests
observed.ShouldBeEmpty();
}
/// <summary>Verifies that UnsubscribeAlarmsAsync throws for a foreign handle.</summary>
[Fact]
public async Task UnsubscribeAlarmsAsync_throws_for_foreign_handle()
{
@@ -92,6 +96,7 @@ public sealed class GalaxyDriverAlarmSourceTests
driver.UnsubscribeAlarmsAsync(foreignHandle, CancellationToken.None));
}
/// <summary>Verifies that AcknowledgeAsync routes each request to the acknowledger.</summary>
[Fact]
public async Task AcknowledgeAsync_routes_each_request_to_the_acknowledger()
{
@@ -113,6 +118,7 @@ public sealed class GalaxyDriverAlarmSourceTests
ack.Calls[1].AlarmRef.ShouldBe("Tank02.Level.HiHi");
}
/// <summary>Verifies that AcknowledgeAsync falls back to SourceNodeId when ConditionId is empty.</summary>
[Fact]
public async Task AcknowledgeAsync_falls_back_to_SourceNodeId_when_ConditionId_empty()
{
@@ -127,6 +133,7 @@ public sealed class GalaxyDriverAlarmSourceTests
ack.Calls[0].AlarmRef.ShouldBe("Tank01.Level.HiHi");
}
/// <summary>Verifies that AcknowledgeAsync throws NotSupportedException without an acknowledger.</summary>
[Fact]
public async Task AcknowledgeAsync_throws_NotSupported_without_acknowledger()
{
@@ -178,22 +185,37 @@ public sealed class GalaxyDriverAlarmSourceTests
/// <summary>In-memory <see cref="IGalaxyAlarmFeed"/> the test drives directly.</summary>
private sealed class FakeAlarmFeed : IGalaxyAlarmFeed
{
/// <summary>Gets a value indicating whether the feed has been started.</summary>
public bool Started { get; private set; }
/// <summary>Occurs when an alarm transition is emitted.</summary>
public event EventHandler<GalaxyAlarmTransition>? OnAlarmTransition;
/// <summary>Marks the feed as started.</summary>
public void Start() => Started = true;
/// <summary>Emits an alarm transition to all subscribers.</summary>
/// <param name="transition">The transition to emit.</param>
public void Emit(GalaxyAlarmTransition transition)
=> OnAlarmTransition?.Invoke(this, transition);
/// <summary>Disposes the fake feed.</summary>
/// <returns>A completed task.</returns>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
/// <summary>Test double that records all acknowledge calls.</summary>
private sealed class RecordingAcknowledger : IGalaxyAlarmAcknowledger
{
/// <summary>Gets the list of acknowledge calls recorded.</summary>
public List<(string AlarmRef, string Comment, string Operator)> Calls { get; } = [];
/// <summary>Records an acknowledge call.</summary>
/// <param name="alarmFullReference">The alarm full reference.</param>
/// <param name="comment">The acknowledgment comment.</param>
/// <param name="operatorUser">The operator user.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A completed task.</returns>
public Task AcknowledgeAsync(string alarmFullReference, string comment, string operatorUser, CancellationToken cancellationToken)
{
Calls.Add((alarmFullReference, comment, operatorUser));
@@ -201,8 +223,10 @@ public sealed class GalaxyDriverAlarmSourceTests
}
}
/// <summary>Test double that represents a foreign alarm subscription handle.</summary>
private sealed class ForeignAlarmHandle : IAlarmSubscriptionHandle
{
/// <summary>Gets the diagnostic ID for this handle.</summary>
public string DiagnosticId => "foreign";
}
}
@@ -12,12 +12,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests;
/// </summary>
public sealed class GalaxyDriverApiKeyResolverTests
{
/// <summary>Verifies that a literal string is returned unchanged.</summary>
[Fact]
public void Literal_string_is_returned_unchanged()
{
GalaxyDriver.ResolveApiKey("plain-text-key").ShouldBe("plain-text-key");
}
/// <summary>Verifies that env: prefix resolves to an environment variable.</summary>
[Fact]
public void Env_prefix_resolves_to_environment_variable()
{
@@ -33,6 +35,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
}
}
/// <summary>Verifies that unset environment variables throw with a descriptive message.</summary>
[Fact]
public void Env_prefix_unset_variable_throws_with_descriptive_message()
{
@@ -45,6 +48,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
ex.Message.ShouldContain("unset");
}
/// <summary>Verifies that file: prefix resolves to trimmed file contents.</summary>
[Fact]
public void File_prefix_resolves_to_trimmed_file_contents()
{
@@ -60,6 +64,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
}
}
/// <summary>Verifies that file: prefix with missing path throws.</summary>
[Fact]
public void File_prefix_missing_path_throws()
{
@@ -72,6 +77,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
// ===== Driver.Galaxy-010 regression: literal arm warns + dev: prefix path =====
/// <summary>Verifies that literal strings emit a warning when a logger is supplied.</summary>
[Fact]
public void Literal_string_emits_warning_when_logger_supplied()
{
@@ -86,6 +92,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
e.Level == LogLevel.Warning && e.Message.Contains("literal", StringComparison.OrdinalIgnoreCase));
}
/// <summary>Verifies that dev: prefix returns literal text without emitting warnings.</summary>
[Fact]
public void Dev_prefix_returns_literal_without_warning()
{
@@ -99,6 +106,7 @@ public sealed class GalaxyDriverApiKeyResolverTests
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
}
/// <summary>Verifies that env: prefix does not emit literal string warnings.</summary>
[Fact]
public void Env_prefix_does_not_emit_literal_warning()
{
@@ -116,15 +124,24 @@ public sealed class GalaxyDriverApiKeyResolverTests
}
}
/// <summary>A test logger that captures log entries for verification.</summary>
private sealed class CaptureLogger : ILogger
{
/// <summary>Gets the list of captured log entries with their levels and messages.</summary>
public List<(LogLevel Level, string Message)> Entries { get; } = new();
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc />
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
=> Entries.Add((logLevel, formatter(state, exception)));
}
/// <summary>Verifies that file: prefix with empty file throws.</summary>
[Fact]
public void File_prefix_empty_file_throws()
{
@@ -23,6 +23,7 @@ public sealed class GalaxyDriverFactoryTests
}
""";
/// <summary>Verifies that minimal config is parsed and defaults are applied.</summary>
[Fact]
public void CreateInstance_ParsesMinimalConfig_AndAppliesDefaults()
{
@@ -40,6 +41,7 @@ public sealed class GalaxyDriverFactoryTests
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeTrue();
}
/// <summary>Verifies that defaults are overridden from full config.</summary>
[Fact]
public void CreateInstance_OverridesDefaults_FromFullConfig()
{
@@ -77,6 +79,7 @@ public sealed class GalaxyDriverFactoryTests
driver.Options.Reconnect.ReplayOnSessionLost.ShouldBeFalse();
}
/// <summary>Verifies that missing endpoint throws an exception.</summary>
[Fact]
public void CreateInstance_MissingEndpoint_Throws()
{
@@ -85,6 +88,7 @@ public sealed class GalaxyDriverFactoryTests
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("Gateway.Endpoint");
}
/// <summary>Verifies that missing API key throws an exception.</summary>
[Fact]
public void CreateInstance_MissingApiKey_Throws()
{
@@ -93,6 +97,7 @@ public sealed class GalaxyDriverFactoryTests
() => GalaxyDriverFactoryExtensions.CreateInstance("g", bad)).Message.ShouldContain("ApiKeySecretRef");
}
/// <summary>Verifies that missing client name throws an exception.</summary>
[Fact]
public void CreateInstance_MissingClientName_Throws()
{
@@ -101,6 +106,7 @@ public sealed class GalaxyDriverFactoryTests
() => 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()
{
@@ -118,6 +124,7 @@ public sealed class GalaxyDriverFactoryTests
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()
{
@@ -140,6 +147,7 @@ public sealed class GalaxyDriverFactoryTests
await driver.FlushOptionalCachesAsync(CancellationToken.None); // no-op shouldn't throw
}
/// <summary>Verifies that reinitializing with equivalent config refreshes health.</summary>
[Fact]
public async Task ReinitializeAsync_RefreshesHealth_WhenConfigIsEquivalent()
{
@@ -182,13 +190,16 @@ public sealed class GalaxyDriverFactoryTests
/// </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)
{
@@ -197,6 +208,7 @@ public sealed class GalaxyDriverFactoryTests
}
}
/// <summary>Verifies that dispose is idempotent and shutdown after dispose is harmless.</summary>
[Fact]
public void Dispose_IsIdempotent_AndShutdownAfterDisposeIsHarmless()
{
@@ -205,6 +217,7 @@ public sealed class GalaxyDriverFactoryTests
Should.NotThrow(() => driver.Dispose());
}
/// <summary>Verifies that initializing after dispose throws an exception.</summary>
[Fact]
public async Task InitializeAfterDispose_Throws()
{
@@ -214,6 +227,7 @@ public sealed class GalaxyDriverFactoryTests
driver.InitializeAsync(MinimalConfig, CancellationToken.None));
}
/// <summary>Verifies that the driver implements all Phase 4 capabilities.</summary>
[Fact]
public void DriverImplementsAllPhase4Capabilities()
{
@@ -229,6 +243,7 @@ public sealed class GalaxyDriverFactoryTests
driver.ShouldBeAssignableTo<IHostConnectivityProbe>();
}
/// <summary>Verifies that GetHostStatuses returns an empty snapshot after initialization with seam.</summary>
[Fact]
public async Task GetHostStatuses_AfterInitWithSeam_ReturnsEmptySnapshot()
{
@@ -243,6 +258,7 @@ public sealed class GalaxyDriverFactoryTests
driver.GetHostStatuses().ShouldBeEmpty();
}
/// <summary>Verifies that the driver type is GalaxyMxGateway, not the legacy Galaxy type.</summary>
[Fact]
public void DriverType_IsGalaxyMxGateway_NotLegacyGalaxy()
{
@@ -23,6 +23,7 @@ public sealed class GalaxyDriverInfrastructureTests
// ===== Driver.Galaxy-011 regression: GetMemoryFootprint reflects registry size =====
/// <summary>Verifies that memory footprint is zero when there are no subscriptions.</summary>
[Fact]
public void GetMemoryFootprint_IsZeroWhenNoSubscriptions()
{
@@ -32,6 +33,7 @@ public sealed class GalaxyDriverInfrastructureTests
driver.GetMemoryFootprint().ShouldBe(0L);
}
/// <summary>Verifies that memory footprint is nonzero after subscriptions are active.</summary>
[Fact]
public async Task GetMemoryFootprint_IsNonZeroAfterSubscribe()
{
@@ -45,6 +47,7 @@ public sealed class GalaxyDriverInfrastructureTests
driver.GetMemoryFootprint().ShouldBeGreaterThan(0L);
}
/// <summary>Verifies that memory footprint decreases after unsubscribing.</summary>
[Fact]
public async Task GetMemoryFootprint_DecreasesAfterUnsubscribe()
{
@@ -63,6 +66,7 @@ public sealed class GalaxyDriverInfrastructureTests
// ===== Driver.Galaxy-007 regression: Dispose cancels the dispose CTS =====
/// <summary>Verifies that Dispose sets disposed flag and blocks further capability calls.</summary>
[Fact]
public async Task Dispose_SetsDisposedFlag_BlockingFurtherCapabilityCalls()
{
@@ -76,6 +80,7 @@ public sealed class GalaxyDriverInfrastructureTests
driver.SubscribeAsync(["Tag.A"], TimeSpan.Zero, CancellationToken.None));
}
/// <summary>Verifies that DisposeAsync can be awaited without deadlock.</summary>
[Fact]
public async Task DisposeAsync_CanBeAwaitedWithoutDeadlock()
{
@@ -87,6 +92,7 @@ public sealed class GalaxyDriverInfrastructureTests
// ===== Driver.Galaxy-013 regression: ReplayOnSessionLost gates the replay step =====
/// <summary>Verifies that ReplayOnSessionLost=false skips resubscription on reconnect.</summary>
[Fact]
public async Task ReplayOnSessionLost_False_SkipsResubscribeBulk()
{
@@ -116,6 +122,7 @@ public sealed class GalaxyDriverInfrastructureTests
"ReplayOnSessionLost=false must skip the re-SubscribeBulk fan-out on reconnect");
}
/// <summary>Verifies that ReplayOnSessionLost=true runs resubscription on reconnect.</summary>
[Fact]
public async Task ReplayOnSessionLost_True_RunsResubscribeBulk()
{
@@ -137,12 +144,18 @@ public sealed class GalaxyDriverInfrastructureTests
"default ReplayOnSessionLost=true must re-issue SubscribeBulk after a transport drop");
}
/// <summary>Tracks subscription calls for replay testing.</summary>
private sealed class ReplayCountingSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
private int _nextHandle = 1;
/// <summary>Gets the count of subscription calls.</summary>
public int SubscribeCalls;
/// <summary>Subscribes to multiple tags and counts the call.</summary>
/// <param name="fullReferences">List of tag addresses to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -156,15 +169,21 @@ public sealed class GalaxyDriverInfrastructureTests
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Unsubscribes from multiple tags.</summary>
/// <param name="itemHandles">List of subscription handles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams subscription events.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
}
// ===== Driver.Galaxy-013 regression: ReinitializeAsync rejects unsupported reapply =====
/// <summary>Verifies that ReinitializeAsync rejects non-equivalent config changes.</summary>
[Fact]
public async Task ReinitializeAsync_RejectsNonEquivalentConfigChange()
{
@@ -183,6 +202,7 @@ public sealed class GalaxyDriverInfrastructureTests
await driver.ReinitializeAsync(newConfig, CancellationToken.None));
}
/// <summary>Verifies that ReinitializeAsync accepts equivalent config.</summary>
[Fact]
public async Task ReinitializeAsync_AcceptsEquivalentConfig()
{
@@ -202,10 +222,15 @@ public sealed class GalaxyDriverInfrastructureTests
// ===== Minimal IGalaxySubscriber fake that returns empty results for subscribe calls =====
/// <summary>No-op subscriber that returns empty results for testing.</summary>
private sealed class NoOpSubscriber : IGalaxySubscriber
{
private readonly Channel<MxEvent> _stream = Channel.CreateUnbounded<MxEvent>();
/// <summary>Subscribes to multiple tags (no-op).</summary>
/// <param name="fullReferences">List of tag addresses to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">Buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -218,9 +243,14 @@ public sealed class GalaxyDriverInfrastructureTests
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Unsubscribes from multiple tags (no-op).</summary>
/// <param name="itemHandles">List of subscription handles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams subscription events (no-op).</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
}
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Health;
/// </summary>
public sealed class HostConnectivityForwarderTests
{
/// <summary>Verifies that SetTransport pushes the state change under the client name.</summary>
[Fact]
public void SetTransport_Running_PushesUnderClientName()
{
@@ -30,6 +31,7 @@ public sealed class HostConnectivityForwarderTests
agg.Snapshot()[0].HostName.ShouldBe("OtOpcUa-A");
}
/// <summary>Verifies that SetTransport fires a change event on state transitions.</summary>
[Fact]
public void SetTransport_StateTransition_FiresChange()
{
@@ -47,6 +49,7 @@ public sealed class HostConnectivityForwarderTests
captured[0].NewState.ShouldBe(HostState.Stopped);
}
/// <summary>Verifies that repeated identical state changes do not fire events.</summary>
[Fact]
public void SetTransport_RepeatedSameState_DoesNotFire()
{
@@ -64,6 +67,7 @@ public sealed class HostConnectivityForwarderTests
captured.ShouldBeEmpty();
}
/// <summary>Verifies that the constructor rejects empty client names.</summary>
[Fact]
public void Constructor_RejectsEmptyClientName()
{
@@ -72,6 +76,7 @@ public sealed class HostConnectivityForwarderTests
Should.Throw<ArgumentException>(() => new HostConnectivityForwarder(" ", agg));
}
/// <summary>Verifies that SetTransport throws after the forwarder is disposed.</summary>
[Fact]
public void SetTransport_AfterDispose_Throws()
{
@@ -15,6 +15,7 @@ public sealed class HostStatusAggregatorTests
private static HostConnectivityStatus Status(string host, HostState state) =>
new(host, state, DateTime.UtcNow);
/// <summary>Verifies that snapshot is empty when nothing is tracked.</summary>
[Fact]
public void Snapshot_Empty_WhenNothingTracked()
{
@@ -22,6 +23,7 @@ public sealed class HostStatusAggregatorTests
agg.Snapshot().ShouldBeEmpty();
}
/// <summary>Verifies that updating a new host fires a change event with Unknown as previous state.</summary>
[Fact]
public void Update_NewHost_FiresChange_PreviousIsUnknown()
{
@@ -37,6 +39,7 @@ public sealed class HostStatusAggregatorTests
captured[0].NewState.ShouldBe(HostState.Running);
}
/// <summary>Verifies that updating to the same state does not fire a change event.</summary>
[Fact]
public void Update_SameState_DoesNotFire()
{
@@ -51,6 +54,7 @@ public sealed class HostStatusAggregatorTests
captured.ShouldBeEmpty();
}
/// <summary>Verifies that state transitions fire change events with correct old and new states.</summary>
[Fact]
public void Update_StateTransition_FiresChangeWithCorrectPreviousAndNew()
{
@@ -67,6 +71,7 @@ public sealed class HostStatusAggregatorTests
captured[0].NewState.ShouldBe(HostState.Stopped);
}
/// <summary>Verifies that snapshot reflects every upserted host.</summary>
[Fact]
public void Snapshot_ReflectsEveryUpsertedHost()
{
@@ -82,6 +87,7 @@ public sealed class HostStatusAggregatorTests
snap.First(s => s.HostName == "PlatformB").State.ShouldBe(HostState.Stopped);
}
/// <summary>Verifies that host name comparison is case-insensitive.</summary>
[Fact]
public void Update_HostNameComparison_IsCaseInsensitive()
{
@@ -98,6 +104,7 @@ public sealed class HostStatusAggregatorTests
agg.Snapshot().Count.ShouldBe(1);
}
/// <summary>Verifies that removing a tracked host returns true and drops it from snapshot.</summary>
[Fact]
public void Remove_TrackedHost_ReturnsTrue_AndDropsFromSnapshot()
{
@@ -107,6 +114,7 @@ public sealed class HostStatusAggregatorTests
agg.Snapshot().ShouldBeEmpty();
}
/// <summary>Verifies that removing an unknown host returns false.</summary>
[Fact]
public void Remove_UnknownHost_ReturnsFalse()
{
@@ -114,6 +122,7 @@ public sealed class HostStatusAggregatorTests
agg.Remove("Nope").ShouldBeFalse();
}
/// <summary>Verifies that concurrent updates do not corrupt the internal dictionary.</summary>
[Fact]
public void ConcurrentUpdates_DoNotCorruptDictionary()
{
@@ -17,12 +17,20 @@ public sealed class PerPlatformProbeWatcherTests
{
private sealed class FakeSubscriber : IGalaxySubscriber
{
/// <summary>Gets a list of all subscribe requests made to the subscriber.</summary>
public List<List<string>> Subscribes { get; } = [];
/// <summary>Gets the buffered update intervals used in each subscribe request.</summary>
public List<int> SubscribeIntervalsMs { get; } = [];
/// <summary>Gets a list of all unsubscribe requests made to the subscriber.</summary>
public List<List<int>> Unsubscribes { get; } = [];
private int _nextHandle = 1;
/// <summary>Gets a mapping of tag addresses to their assigned item handles.</summary>
public Dictionary<string, int> HandleByAddress { get; } = new(StringComparer.OrdinalIgnoreCase);
/// <summary>Simulates a bulk subscribe operation by generating handles for each reference.</summary>
/// <param name="fullReferences">The list of tag addresses to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -43,12 +51,17 @@ public sealed class PerPlatformProbeWatcherTests
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Simulates a bulk unsubscribe operation by recording the handles.</summary>
/// <param name="itemHandles">The list of item handles to unsubscribe.</param>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
Unsubscribes.Add([.. itemHandles]);
return Task.CompletedTask;
}
/// <summary>Returns an empty event stream for testing.</summary>
/// <param name="cancellationToken">The cancellation token for the operation.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> Empty();
@@ -59,6 +72,7 @@ public sealed class PerPlatformProbeWatcherTests
}
}
/// <summary>Verifies that syncing platforms subscribes to the ScanState address for each platform.</summary>
[Fact]
public async Task SyncPlatformsAsync_SubscribesScanStateAddressForEachPlatform()
{
@@ -73,6 +87,7 @@ public sealed class PerPlatformProbeWatcherTests
watcher.WatchedPlatforms.OrderBy(x => x).ShouldBe(new[] { "PlatformA", "PlatformB" });
}
/// <summary>Verifies that the default buffered interval is zero, matching gateway cadence.</summary>
[Fact]
public async Task SyncPlatformsAsync_DefaultBufferedIntervalIsZero_GwCadence()
{
@@ -84,6 +99,7 @@ public sealed class PerPlatformProbeWatcherTests
subscriber.SubscribeIntervalsMs.ShouldHaveSingleItem().ShouldBe(0);
}
/// <summary>Verifies that a configured buffered interval is forwarded to the gateway.</summary>
[Fact]
public async Task SyncPlatformsAsync_ConfiguredBufferedInterval_IsForwardedToGw()
{
@@ -99,6 +115,7 @@ public sealed class PerPlatformProbeWatcherTests
subscriber.SubscribeIntervalsMs.ShouldHaveSingleItem().ShouldBe(250);
}
/// <summary>Verifies that the constructor rejects negative buffered intervals.</summary>
[Fact]
public void Constructor_RejectsNegativeBufferedInterval()
{
@@ -107,6 +124,7 @@ public sealed class PerPlatformProbeWatcherTests
new PerPlatformProbeWatcher(subscriber, new HostStatusAggregator(), bufferedUpdateIntervalMs: -1));
}
/// <summary>Verifies that syncing the same platform set twice does not resubscribe.</summary>
[Fact]
public async Task SyncPlatformsAsync_SameSetTwice_DoesNotResubscribe()
{
@@ -120,6 +138,7 @@ public sealed class PerPlatformProbeWatcherTests
subscriber.Subscribes.Count.ShouldBe(1);
}
/// <summary>Verifies that removed platforms are unsubscribed and dropped from the aggregator.</summary>
[Fact]
public async Task SyncPlatformsAsync_RemovedPlatforms_AreUnsubscribed_AndDroppedFromAggregator()
{
@@ -142,6 +161,10 @@ public sealed class PerPlatformProbeWatcherTests
aggregator.Snapshot().Any(s => s.HostName == "B").ShouldBeFalse();
}
/// <summary>Verifies that DecodeState correctly decodes ScanState values and quality bytes across multiple pin configurations.</summary>
/// <param name="value">The probe value to decode.</param>
/// <param name="qualityByte">The OPC UA quality byte indicating data validity.</param>
/// <param name="expected">The expected decoded host state.</param>
[Theory]
[InlineData(true, (byte)192, HostState.Running)]
[InlineData(false, (byte)192, HostState.Stopped)]
@@ -159,6 +182,7 @@ public sealed class PerPlatformProbeWatcherTests
PerPlatformProbeWatcher.DecodeState(value, qualityByte).ShouldBe(expected);
}
/// <summary>Verifies that a running probe value is routed to the aggregator.</summary>
[Fact]
public async Task OnProbeValueChanged_Running_RoutesToAggregator()
{
@@ -173,6 +197,7 @@ public sealed class PerPlatformProbeWatcherTests
snap.State.ShouldBe(HostState.Running);
}
/// <summary>Verifies that a probe value with bad quality routes as unknown state.</summary>
[Fact]
public async Task OnProbeValueChanged_BadQuality_RoutesUnknown()
{
@@ -186,6 +211,7 @@ public sealed class PerPlatformProbeWatcherTests
aggregator.Snapshot().Single(s => s.HostName == "PlatformA").State.ShouldBe(HostState.Unknown);
}
/// <summary>Verifies that foreign probe references are silently dropped.</summary>
[Fact]
public async Task OnProbeValueChanged_ForeignReference_IsSilentlyDropped()
{
@@ -204,6 +230,7 @@ public sealed class PerPlatformProbeWatcherTests
aggregator.Snapshot().Any(s => s.HostName == "Stranger").ShouldBeFalse();
}
/// <summary>Verifies that dispose unsubscribes all tracked platforms.</summary>
[Fact]
public async Task Dispose_UnsubscribesAllTrackedPlatforms()
{
@@ -17,6 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Runtime;
/// </summary>
public sealed class EventPumpBoundedChannelTests
{
/// <summary>Verifies that the event pump drops newest events when the bounded channel fills and records metrics for dropped events.</summary>
[Fact]
public async Task Drops_newest_when_channel_fills_and_records_metric()
{
@@ -66,6 +67,7 @@ public sealed class EventPumpBoundedChannelTests
}
}
/// <summary>Verifies that the event pump throws an exception when the channel capacity is invalid.</summary>
[Fact]
public async Task Throws_when_channelCapacity_is_invalid()
{
@@ -78,6 +80,7 @@ public sealed class EventPumpBoundedChannelTests
await Task.CompletedTask;
}
/// <summary>Verifies that event pump metrics are tagged with the client name for tracking multiple driver hosts.</summary>
[Fact]
public async Task Tags_metrics_with_client_name_for_multi_driver_hosts()
{
@@ -169,10 +172,15 @@ public sealed class EventPumpBoundedChannelTests
{
public MeterListener? Listener;
internal long _received, _dispatched, _dropped;
/// <summary>Gets the count of received events.</summary>
public long Received => Interlocked.Read(ref _received);
/// <summary>Gets the count of dispatched events.</summary>
public long Dispatched => Interlocked.Read(ref _dispatched);
/// <summary>Gets the count of dropped events.</summary>
public long Dropped => Interlocked.Read(ref _dropped);
/// <summary>Gets the count of in-flight events.</summary>
public long InFlight => Math.Max(0, Received - Dispatched - Dropped);
/// <summary>Disposes the meter listener.</summary>
public void Dispose() => Listener?.Dispose();
}
@@ -181,16 +189,32 @@ public sealed class EventPumpBoundedChannelTests
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
/// <summary>Subscribes to a bulk list of tag references.</summary>
/// <param name="fullReferences">The list of full references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An empty result list.</returns>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
/// <summary>Unsubscribes from a bulk list of item handles.</summary>
/// <param name="itemHandles">The list of item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A completed task.</returns>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams events asynchronously.</summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>An async enumerable of MxEvent objects.</returns>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
/// <summary>Emits a test event with the specified item handle and value.</summary>
/// <param name="itemHandle">The item handle.</param>
/// <param name="value">The event value.</param>
/// <returns>A completed value task.</returns>
public ValueTask EmitAsync(int itemHandle, double value) =>
_stream.Writer.WriteAsync(new MxEvent
{
@@ -18,6 +18,7 @@ public sealed class EventPumpStreamFaultTests
{
private const int WaitMs = 2_000;
/// <summary>Verifies that stream fault invokes the callback with the exception.</summary>
[Fact]
public async Task StreamFault_InvokesOnStreamFaultCallback_WithTheCause()
{
@@ -40,6 +41,7 @@ public sealed class EventPumpStreamFaultTests
(await faultObserved.Task).ShouldBeOfType<IOException>();
}
/// <summary>Verifies that stream fault drives the reconnect supervisor through reopen and replay.</summary>
[Fact]
public async Task StreamFault_DrivesReconnectSupervisorReopenReplay()
{
@@ -76,6 +78,7 @@ public sealed class EventPumpStreamFaultTests
supervisor.IsDegraded.ShouldBeFalse();
}
/// <summary>Verifies that a faulted pump cannot be restarted in place, but a fresh pump resumes dispatch.</summary>
[Fact]
public async Task FaultedPump_IsNotRestartableInPlace_ButAFreshPumpResumesDispatch()
{
@@ -118,6 +121,7 @@ public sealed class EventPumpStreamFaultTests
staleObserved.ShouldBeFalse("the faulted pump must not dispatch after its stream dropped");
}
/// <summary>Verifies that clean shutdown does not invoke the stream fault callback.</summary>
[Fact]
public async Task CleanShutdown_DoesNotInvokeOnStreamFault()
{
@@ -146,17 +150,27 @@ public sealed class EventPumpStreamFaultTests
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
/// <summary>Subscribes to multiple tags (test stub).</summary>
/// <param name="fullReferences">The tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
/// <summary>Unsubscribes from multiple tags (test stub).</summary>
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams events asynchronously (test stub).</summary>
/// <param name="cancellationToken">The cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
/// <summary>Fault the stream so the pump's <c>await foreach</c> throws.</summary>
/// <param name="cause">The exception to complete the stream with.</param>
public void FaultStream(Exception cause) => _stream.Writer.TryComplete(cause);
}
@@ -169,16 +183,28 @@ public sealed class EventPumpStreamFaultTests
private readonly Channel<MxEvent> _stream =
Channel.CreateUnbounded<MxEvent>(new UnboundedChannelOptions { SingleReader = true });
/// <summary>Subscribes to multiple tags (test stub).</summary>
/// <param name="fullReferences">The tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
=> Task.FromResult<IReadOnlyList<SubscribeResult>>([]);
/// <summary>Unsubscribes from multiple tags (test stub).</summary>
/// <param name="itemHandles">The item handles to unsubscribe from.</param>
/// <param name="cancellationToken">The cancellation token.</param>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
=> Task.CompletedTask;
/// <summary>Streams events asynchronously (test stub).</summary>
/// <param name="cancellationToken">The cancellation token.</param>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _stream.Reader.ReadAllAsync(cancellationToken);
/// <summary>Emits a data change event asynchronously.</summary>
/// <param name="itemHandle">The item handle for the data change.</param>
/// <param name="value">The numeric value of the change.</param>
public ValueTask EmitAsync(int itemHandle, double value) =>
_stream.Writer.WriteAsync(new MxEvent
{
@@ -23,7 +23,9 @@ public sealed class GalaxyDriverReadTests
private sealed class FakeReader : IGalaxyDataReader
{
/// <summary>Gets the last read request.</summary>
public IReadOnlyList<string>? LastRequest { get; private set; }
/// <summary>Gets or sets the function that decides the result for a given tag list.</summary>
public Func<IReadOnlyList<string>, IReadOnlyList<DataValueSnapshot>> Decide { get; set; } =
tags => tags.Select(t => new DataValueSnapshot(
Value: t,
@@ -31,6 +33,7 @@ public sealed class GalaxyDriverReadTests
SourceTimestampUtc: DateTime.UtcNow,
ServerTimestampUtc: DateTime.UtcNow)).ToArray();
/// <inheritdoc />
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
@@ -39,6 +42,7 @@ public sealed class GalaxyDriverReadTests
}
}
/// <summary>Verifies that ReadAsync routes through the injected reader.</summary>
[Fact]
public async Task ReadAsync_RoutesThroughInjectedReader()
{
@@ -53,6 +57,7 @@ public sealed class GalaxyDriverReadTests
result[0].StatusCode.ShouldBe(StatusCodeMap.Good);
}
/// <summary>Verifies that ReadAsync returns empty without calling the reader for an empty request.</summary>
[Fact]
public async Task ReadAsync_EmptyRequest_ReturnsEmpty_WithoutCallingReader()
{
@@ -65,6 +70,7 @@ public sealed class GalaxyDriverReadTests
reader.LastRequest.ShouldBeNull();
}
/// <summary>Verifies that ReadAsync throws when seams and production runtime are not built.</summary>
[Fact]
public async Task ReadAsync_NoSeams_AndNoProductionRuntime_Throws()
{
@@ -79,6 +85,7 @@ public sealed class GalaxyDriverReadTests
ex.Message.ShouldContain("production runtime not built");
}
/// <summary>Verifies that ReadAsync throws after the driver is disposed.</summary>
[Fact]
public async Task ReadAsync_AfterDispose_Throws()
{
@@ -88,6 +95,7 @@ public sealed class GalaxyDriverReadTests
driver.ReadAsync(["x"], CancellationToken.None));
}
/// <summary>Verifies that ReadAsync resolves from the first OnDataChange event on the subscribe-once path.</summary>
[Fact]
public async Task ReadAsync_SubscribeOncePath_ResolvesFromFirstOnDataChange()
{
@@ -111,6 +119,7 @@ public sealed class GalaxyDriverReadTests
subscriber.UnsubscribedHandles.ShouldContain(itemHandle);
}
/// <summary>Verifies that ReadAsync surfaces rejected tags as bad status on the subscribe-once path.</summary>
[Fact]
public async Task ReadAsync_SubscribeOncePath_RejectedTagSurfacesAsBadStatus()
{
@@ -130,6 +139,7 @@ public sealed class GalaxyDriverReadTests
result[1].StatusCode.ShouldBe(0x80000000u); // Bad
}
/// <summary>Verifies that ReadAsync preserves reader status codes.</summary>
[Fact]
public async Task ReadAsync_PreservesReaderStatusCodes()
{
@@ -27,11 +27,24 @@ public sealed class GalaxyDriverSubscribeTests
{
private int _nextHandle = 1;
private readonly Channel<MxEvent> _events = Channel.CreateUnbounded<MxEvent>();
/// <summary>Gets the mapping of tag references to subscription handles.</summary>
public Dictionary<string, int> Map { get; } = new();
/// <summary>Gets the list of unsubscribed handles.</summary>
public List<int> UnsubscribedHandles { get; } = [];
/// <summary>Gets the list of buffered intervals called.</summary>
public List<int> BufferedIntervalsCalled { get; } = [];
/// <summary>Gets or sets a function to decide whether to accept a subscription.</summary>
public Func<string, bool> Decide { get; set; } = _ => true;
/// <summary>Subscribes to bulk updates for the specified tag references.</summary>
/// <param name="fullReferences">The tag references to subscribe to.</param>
/// <param name="bufferedUpdateIntervalMs">The buffered update interval in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A list of subscription results.</returns>
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
{
@@ -64,15 +77,27 @@ public sealed class GalaxyDriverSubscribeTests
return Task.FromResult<IReadOnlyList<SubscribeResult>>(results);
}
/// <summary>Unsubscribes from bulk updates for the specified item handles.</summary>
/// <param name="itemHandles">The handles to unsubscribe.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
{
UnsubscribedHandles.AddRange(itemHandles);
return Task.CompletedTask;
}
/// <summary>Streams events asynchronously.</summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An async enumerable of MX events.</returns>
public IAsyncEnumerable<MxEvent> StreamEventsAsync(CancellationToken cancellationToken)
=> _events.Reader.ReadAllAsync(cancellationToken);
/// <summary>Emits a data change event asynchronously.</summary>
/// <param name="itemHandle">The handle of the item that changed.</param>
/// <param name="value">The new value.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A value task representing the asynchronous emission.</returns>
public ValueTask EmitOnDataChangeAsync(int itemHandle, double value, byte quality = 192) =>
_events.Writer.WriteAsync(new MxEvent
{
@@ -83,9 +108,11 @@ public sealed class GalaxyDriverSubscribeTests
SourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow),
});
/// <summary>Completes the event stream.</summary>
public void CompleteEvents() => _events.Writer.Complete();
}
/// <summary>Verifies subscription allocates a handle and dispatches value changes.</summary>
[Fact]
public async Task SubscribeAsync_AllocatesHandle_AndDispatchesValueChange()
{
@@ -108,6 +135,7 @@ public sealed class GalaxyDriverSubscribeTests
((double)captured[0].Snapshot.Value!).ShouldBe(42.0);
}
/// <summary>Verifies two subscriptions for the same tag each receive updates.</summary>
[Fact]
public async Task SubscribeAsync_TwoSubscriptions_SameTag_FanOutOnePerSubscription()
{
@@ -151,6 +179,7 @@ public sealed class GalaxyDriverSubscribeTests
allowed.ShouldContain(captured0Id);
}
/// <summary>Verifies failed subscriptions do not dispatch events.</summary>
[Fact]
public async Task SubscribeAsync_FailedTag_DoesNotDispatchEvents()
{
@@ -171,6 +200,7 @@ public sealed class GalaxyDriverSubscribeTests
captured.ShouldBeEmpty();
}
/// <summary>Verifies unsubscribe removes registration and calls gateway unsubscribe.</summary>
[Fact]
public async Task UnsubscribeAsync_RemovesRegistration_AndCallsGwUnsubscribe()
{
@@ -193,6 +223,7 @@ public sealed class GalaxyDriverSubscribeTests
captured.ShouldBeEmpty();
}
/// <summary>Verifies unsubscribing with an unknown handle is handled.</summary>
[Fact]
public async Task UnsubscribeAsync_UnknownHandle_NoOp()
{
@@ -207,6 +238,7 @@ public sealed class GalaxyDriverSubscribeTests
driver.UnsubscribeAsync(foreignHandle, CancellationToken.None));
}
/// <summary>Verifies subscription without a subscriber throws.</summary>
[Fact]
public async Task SubscribeAsync_NoSubscriber_Throws()
{
@@ -216,6 +248,7 @@ public sealed class GalaxyDriverSubscribeTests
ex.Message.ShouldContain("PR 4.W");
}
/// <summary>Verifies subscription falls back to configured interval when zero is passed.</summary>
[Fact]
public async Task SubscribeAsync_FallsBackToConfiguredInterval_WhenCallerPassesZero()
{
@@ -235,6 +268,7 @@ public sealed class GalaxyDriverSubscribeTests
subscriber.BufferedIntervalsCalled.ShouldHaveSingleItem().ShouldBe(750);
}
/// <summary>Verifies subscription respects caller's interval when non-zero.</summary>
[Fact]
public async Task SubscribeAsync_RespectsCallerInterval_WhenNonZero()
{
@@ -254,6 +288,7 @@ public sealed class GalaxyDriverSubscribeTests
subscriber.BufferedIntervalsCalled.ShouldHaveSingleItem().ShouldBe(250);
}
/// <summary>Verifies subscription with empty tag list returns handle without calling gateway.</summary>
[Fact]
public async Task SubscribeAsync_EmptyTagList_ReturnsHandleWithoutCallingGw()
{
@@ -266,8 +301,10 @@ public sealed class GalaxyDriverSubscribeTests
subscriber.Map.ShouldBeEmpty();
}
/// <summary>A subscription handle from a foreign source.</summary>
private sealed class ForeignHandle : ISubscriptionHandle
{
/// <summary>Gets the diagnostic identifier for this handle.</summary>
public string DiagnosticId => "foreign-x";
}
@@ -26,34 +26,61 @@ public sealed class GalaxyDriverWriteTests
private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
{
/// <summary>Returns the fake Galaxy object hierarchy.</summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
=> Task.FromResult(objects);
}
private sealed class FakeBuilder : IAddressSpaceBuilder
{
/// <summary>Gets the list of variables added to this builder.</summary>
public List<DriverAttributeInfo> Variables { get; } = [];
/// <summary>Adds a folder and returns this builder for chaining.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
/// <summary>Adds a variable to the variables list and returns a handle.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="attributeInfo">The attribute information for the variable.</param>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add(attributeInfo);
return new FakeHandle(attributeInfo.FullName);
}
/// <summary>No-op property adding operation for test compatibility.</summary>
/// <param name="browseName">The browse name of the property.</param>
/// <param name="dataType">The data type of the property.</param>
/// <param name="value">The value of the property.</param>
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class FakeHandle(string fullRef) : IVariableHandle
{
/// <summary>Gets the full reference for this variable handle.</summary>
public string FullReference { get; } = fullRef;
/// <summary>Marks this variable as an alarm condition and returns a noop sink.</summary>
/// <param name="info">The alarm condition information.</param>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
private sealed class NoopSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
/// <summary>No-op alarm transition handler.</summary>
private sealed class NoopSink : IAlarmConditionSink {
/// <summary>Handles alarm state transition events.</summary>
/// <param name="args">The alarm event arguments.</param>
public void OnTransition(AlarmEventArgs args) { }
}
}
}
private sealed class FakeWriter : IGalaxyDataWriter
{
/// <summary>Gets the list of write calls received by this writer.</summary>
public List<(string FullRef, object? Value, SecurityClassification Resolved)> Calls { get; } = [];
/// <summary>Records write requests with their resolved security classifications.</summary>
/// <param name="writes">The list of write requests to process.</param>
/// <param name="securityResolver">Function to resolve security classification for each request.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
public Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes,
Func<string, SecurityClassification> securityResolver,
@@ -79,6 +106,7 @@ public sealed class GalaxyDriverWriteTests
return o;
}
/// <summary>Verifies that WriteAsync routes through the injected writer and propagates values correctly.</summary>
[Fact]
public async Task WriteAsync_RoutesThroughInjectedWriter_AndPropagatesValues()
{
@@ -102,6 +130,9 @@ public sealed class GalaxyDriverWriteTests
writer.Calls[1].Resolved.ShouldBe(SecurityClassification.Operate);
}
/// <summary>Verifies that WriteAsync resolves every security classification from discovery data.</summary>
/// <param name="mxSec">The raw MXAccess security integer from the discovery attribute.</param>
/// <param name="expected">The expected resolved security classification.</param>
[Theory]
[InlineData(0, SecurityClassification.FreeAccess)]
[InlineData(1, SecurityClassification.Operate)]
@@ -125,6 +156,7 @@ public sealed class GalaxyDriverWriteTests
writer.Calls[0].Resolved.ShouldBe(expected);
}
/// <summary>Verifies that unknown tags resolve to FreeAccess classification and writes proceed.</summary>
[Fact]
public async Task WriteAsync_UnknownTag_ResolvesToFreeAccess_DefaultsToWrite()
{
@@ -139,6 +171,7 @@ public sealed class GalaxyDriverWriteTests
writer.Calls[0].Resolved.ShouldBe(SecurityClassification.FreeAccess);
}
/// <summary>Verifies that an empty write request returns empty without calling the writer.</summary>
[Fact]
public async Task WriteAsync_EmptyRequest_ReturnsEmpty_WithoutCallingWriter()
{
@@ -152,6 +185,7 @@ public sealed class GalaxyDriverWriteTests
writer.Calls.ShouldBeEmpty();
}
/// <summary>Verifies that WriteAsync throws when no writer is configured, referencing PR 4.4.</summary>
[Fact]
public async Task WriteAsync_NoWriter_Throws_PointingAtPR44()
{
@@ -162,6 +196,7 @@ public sealed class GalaxyDriverWriteTests
ex.Message.ShouldContain("PR 4.4");
}
/// <summary>Verifies that WriteAsync throws ObjectDisposedException after the driver is disposed.</summary>
[Fact]
public async Task WriteAsync_AfterDispose_Throws()
{

Some files were not shown because too many files have changed in this diff Show More