using System.Linq; using System.Reflection; using CliFx.Attributes; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Commands; using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common; /// /// Locks in the CLI command-option contract surface area — short aliases and /// help-text wording — that the AbLegacy CLI is expected to keep in parity with /// its sibling AbCip CLI and with docs/Driver.AbLegacy.Cli.md. /// Regression coverage for findings Driver.AbLegacy.Cli-002, -005, -006. /// [Trait("Category", "Unit")] public sealed class CommandMetadataTests { private static CommandOptionAttribute GetOption(string propertyName) { var prop = typeof(TCommand).GetProperty( propertyName, BindingFlags.Public | BindingFlags.Instance); prop.ShouldNotBeNull($"property {propertyName} is missing from {typeof(TCommand).Name}"); var attr = prop!.GetCustomAttribute(); attr.ShouldNotBeNull( $"property {propertyName} on {typeof(TCommand).Name} lacks [CommandOption]"); return attr!; } // ---------- Driver.AbLegacy.Cli-006 — ProbeCommand --type needs short alias 't' ---------- /// Verifies that ProbeCommand --type has short alias -t. [Fact] public void ProbeCommand_type_has_short_alias_t() { // Parity with read / write / subscribe: --type / -t works everywhere. var attr = GetOption(nameof(ProbeCommand.DataType)); attr.ShortName.ShouldBe('t'); } /// Verifies that other commands keep the --type short alias as -t. /// The command type to inspect for the --type option. /// The property name of the --type option on the command. [Theory] [InlineData(typeof(ReadCommand), nameof(ReadCommand.DataType))] [InlineData(typeof(WriteCommand), nameof(WriteCommand.DataType))] [InlineData(typeof(SubscribeCommand), nameof(SubscribeCommand.DataType))] public void Other_commands_keep_type_short_alias_t(System.Type commandType, string propName) { var prop = commandType.GetProperty(propName, BindingFlags.Public | BindingFlags.Instance); prop.ShouldNotBeNull(); var attr = prop!.GetCustomAttribute(); attr.ShouldNotBeNull(); attr!.ShortName.ShouldBe('t'); } // ---------- Driver.AbLegacy.Cli-002 — WriteCommand --value help lists full bool alias set ---------- /// Verifies that WriteCommand --value help lists the full boolean alias set. [Fact] public void WriteCommand_value_help_lists_full_boolean_alias_set() { // ParseBool accepts true/false, 1/0, on/off, yes/no — the help text must say so // (DriverClis.md documents the full alias set as the shared CLI contract). var attr = GetOption(nameof(WriteCommand.Value)); attr.Description.ShouldNotBeNull(); attr.Description!.ShouldContain("true/false", Case.Insensitive); attr.Description!.ShouldContain("1/0"); attr.Description!.ShouldContain("on/off", Case.Insensitive); attr.Description!.ShouldContain("yes/no", Case.Insensitive); } // ---------- Driver.AbLegacy.Cli-005 — SubscribeCommand --interval-ms help notes 250ms floor ---------- /// Verifies that SubscribeCommand --interval-ms help notes the PollGroupEngine floor. [Fact] public void SubscribeCommand_interval_ms_help_notes_PollGroupEngine_floor() { // Parity with AbCip CLI: operators passing -i 100 deserve a heads-up that // PollGroupEngine floors sub-250ms values. var attr = GetOption(nameof(SubscribeCommand.IntervalMs)); attr.Description.ShouldNotBeNull(); attr.Description!.ShouldContain("250", Case.Insensitive); } // ---------- Driver.AbLegacy.Cli-008 — all commands must call FlushLogging() in their finally ---------- /// /// Verifies that every AbLegacy CLI command's async state-machine references /// so Serilog output emitted during /// driver shutdown is flushed before process exit (regression for /// Driver.AbLegacy.Cli-008, matching the fix applied to AbCip CLI Driver.AbCip.Cli-005). /// /// The command type whose state machine is inspected. [Theory] [InlineData(typeof(ProbeCommand))] [InlineData(typeof(ReadCommand))] [InlineData(typeof(WriteCommand))] [InlineData(typeof(SubscribeCommand))] public void ExecuteAsync_calls_FlushLogging_in_state_machine(System.Type commandType) { // C# async methods are compiled into a nested 'd__N' state machine // class with a MoveNext() method that contains the actual IL. We scan the // MoveNext() body for a call token that resolves to DriverCommandBase.FlushLogging // using the module's metadata. var flushMethod = typeof(DriverCommandBase) .GetMethod("FlushLogging", BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.FlattenHierarchy); flushMethod.ShouldNotBeNull("DriverCommandBase.FlushLogging must exist"); // Locate the compiler-generated state-machine nested type for ExecuteAsync. var stateMachine = commandType .GetNestedTypes(BindingFlags.NonPublic) .FirstOrDefault(t => t.Name.Contains("ExecuteAsync")); stateMachine.ShouldNotBeNull( $"{commandType.Name} must have a compiler-generated ExecuteAsync state machine"); var moveNext = stateMachine! .GetMethod("MoveNext", BindingFlags.NonPublic | BindingFlags.Instance); moveNext.ShouldNotBeNull("MoveNext must exist on the state machine"); var body = moveNext!.GetMethodBody(); body.ShouldNotBeNull("MoveNext must have an inspectable method body"); // Walk the IL stream: call/callvirt opcodes (0x28 / 0x6F) are followed by a // 4-byte metadata token. Check whether any token resolves to FlushLogging. var il = body!.GetILAsByteArray(); il.ShouldNotBeNull(); var module = moveNext.Module; bool found = false; for (int i = 0; i < il!.Length - 4; i++) { if (il[i] != 0x28 && il[i] != 0x6F) continue; // call / callvirt int token = il[i + 1] | (il[i + 2] << 8) | (il[i + 3] << 16) | (il[i + 4] << 24); try { var resolved = module.ResolveMethod(token); if (resolved?.Name == "FlushLogging" && resolved.DeclaringType == typeof(DriverCommandBase)) { found = true; break; } } catch (ArgumentException) { /* token is a field or type, not a method */ } } found.ShouldBeTrue( $"{commandType.Name}.ExecuteAsync must call FlushLogging() in its finally block " + "so Serilog output during driver shutdown is not lost (Driver.AbLegacy.Cli-008)."); } }