Unit tests for command argument parsing. Exercises CommandArgumentParser, tokenization, type conversion, target resolution, duration parsing, optional/remainder behavior, and syntax generation to ensure parsing rules and constraints are enforced.
using System.Globalization;
using SboxSuperuser.Core.Commands;
using SboxSuperuser.Engine.Commands;
using Xunit;
namespace SboxSuperuser.Contracts.Tests;
public sealed class ParsingTests
{
private const string First = "76561198000000001";
private const string Second = "76561198000000002";
private static CommandDescriptor Descriptor( params CommandArgumentDescriptor[] arguments ) => new( "test.parse", "Parsing", "test.use", "test", arguments );
private static CommandParseResult Parse( CommandArgumentDescriptor argument, string token )
=> CommandArgumentParser.Parse( Descriptor( argument ), new[] { token }, Array.Empty<CommandPlayer>() );
private static CommandArgumentDescriptor Arg( CommandArgumentKind kind, CommandTargetPolicy target = CommandTargetPolicy.None )
=> new( "value", "Value", kind, targetPolicy: target );
[Theory]
[InlineData( CommandArgumentKind.Integer, "1.2" )]
[InlineData( CommandArgumentKind.Integer, "1,000" )]
[InlineData( CommandArgumentKind.Integer, " 1" )]
[InlineData( CommandArgumentKind.Number, "NaN" )]
[InlineData( CommandArgumentKind.Number, "Infinity" )]
[InlineData( CommandArgumentKind.Number, "1e999" )]
[InlineData( CommandArgumentKind.Number, "1,5" )]
[InlineData( CommandArgumentKind.Boolean, "1" )]
[InlineData( CommandArgumentKind.Boolean, "yes" )]
public void InvalidTypedInputFailsWithoutValues( CommandArgumentKind kind, string input )
{
var result = Parse( Arg( kind ), input );
Assert.False( result.IsSuccess );
Assert.Null( result.Arguments );
Assert.DoesNotContain( input, result.Error );
}
[Fact]
public void ConversionUsesInvariantCultureAndCanonicalTypes()
{
var original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( "fr-FR" );
Assert.Equal( 1.5d, Parse( Arg( CommandArgumentKind.Number ), "1.5" ).Arguments.Get<double>( "value" ) );
Assert.Equal( -23L, Parse( Arg( CommandArgumentKind.Integer ), "-23" ).Arguments.Get<long>( "value" ) );
Assert.False( Parse( Arg( CommandArgumentKind.Boolean ), "FALSE" ).Arguments.Get<bool>( "value" ) );
Assert.True( Parse( Arg( CommandArgumentKind.Boolean ), "True" ).Arguments.Get<bool>( "value" ) );
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
[Fact]
public void NumericBoundsAndEnumNamesAreStrict()
{
var number = new CommandArgumentDescriptor( "value", "Value", CommandArgumentKind.Number, minimum: 1, maximum: 5 );
Assert.True( Parse( number, "5" ).IsSuccess );
Assert.False( Parse( number, "5.0001" ).IsSuccess );
var choice = new CommandArgumentDescriptor( "value", "Value", CommandArgumentKind.Enum, choices: new[] { "Calm", "Playful" } );
Assert.Equal( "Playful", Parse( choice, "playFUL" ).Arguments.Get<string>( "value" ) );
Assert.False( Parse( choice, "1" ).IsSuccess );
Assert.False( Parse( choice, "Calm,Playful" ).IsSuccess );
}
[Theory]
[InlineData( "1s", 1 )]
[InlineData( "15m", 900 )]
[InlineData( "2H", 7200 )]
[InlineData( "1d", 86400 )]
[InlineData( "1w", 604800 )]
public void DurationsUseExplicitUnits( string input, int seconds )
{
Assert.Equal( TimeSpan.FromSeconds( seconds ), Parse( Arg( CommandArgumentKind.Duration ), input ).Arguments.Get<CommandDuration>( "value" ).Value );
}
[Fact]
public void PermanentIsExplicitAndZeroIsNeverPermanent()
{
Assert.True( Parse( Arg( CommandArgumentKind.Duration ), "permanent" ).Arguments.Get<CommandDuration>( "value" ).IsPermanent );
Assert.False( Parse( Arg( CommandArgumentKind.Duration ), "0" ).IsSuccess );
Assert.False( Parse( Arg( CommandArgumentKind.Duration ), "0s" ).IsSuccess );
}
[Fact]
public void QuotedNamesAndReasonsResolveToStableIdentity()
{
var descriptor = Descriptor( new CommandArgumentDescriptor( "target", "Target", CommandArgumentKind.Player, targetPolicy: CommandTargetPolicy.OnlinePlayer ),
new CommandArgumentDescriptor( "reason", "Reason", CommandArgumentKind.String, isRemainder: true ) );
var tokens = CommandTokenizer.Tokenize( "\"Jane Doe\" \"quoted reason\" more" ).Tokens;
var parsed = CommandArgumentParser.Parse( descriptor, tokens, new[] { new CommandPlayer( First, "Jane Doe" ) } );
Assert.Equal( First, parsed.Arguments.Get<PlayerReference>( "target" ).SteamId );
Assert.Equal( "quoted reason more", parsed.Arguments.Get<string>( "reason" ) );
}
[Fact]
public void DuplicateNamesReturnChoicesAndNoSelectedArguments()
{
var players = new[] { new CommandPlayer( Second, "Same" ), new CommandPlayer( First, "same" ) };
var descriptor = Descriptor( Arg( CommandArgumentKind.Player, CommandTargetPolicy.AnyPlayer ) );
var ambiguous = CommandArgumentParser.Parse( descriptor, new[] { "SAME" }, players );
Assert.False( ambiguous.IsSuccess );
Assert.Equal( new[] { First, Second }, ambiguous.PlayerChoices.Select( value => value.SteamId ) );
Assert.True( CommandArgumentParser.Parse( descriptor, new[] { Second }, players ).IsSuccess );
Assert.False( CommandArgumentParser.Parse( descriptor, new[] { "Sam" }, players ).IsSuccess );
}
[Fact]
public void ExplicitIdsAreAuthoritativeEvenWhenTheyMatchAnotherPlayersDisplayName()
{
var players = new[] { new CommandPlayer( Second, First ) };
var descriptor = Descriptor( Arg( CommandArgumentKind.Player, CommandTargetPolicy.AnyPlayer ) );
var parsed = CommandArgumentParser.Parse( descriptor, new[] { First }, players );
Assert.Equal( First, parsed.Arguments.Get<PlayerReference>( "value" ).SteamId );
Assert.False( CommandArgumentParser.Parse( Descriptor( Arg( CommandArgumentKind.Player, CommandTargetPolicy.OnlinePlayer ) ), new[] { First }, players ).IsSuccess );
}
[Theory]
[InlineData( "0" )]
[InlineData( "076561198000000001" )]
[InlineData( "18446744073709551616" )]
[InlineData( "@all" )]
[InlineData( "*" )]
public void InvalidOfflineSelectorsAreNeverGuessed( string input )
{
Assert.False( Parse( Arg( CommandArgumentKind.Player, CommandTargetPolicy.AnyPlayer ), input ).IsSuccess );
}
[Fact]
public void MissingOptionalDiffersFromExplicitEmptyAndExtrasFail()
{
var descriptor = Descriptor( new CommandArgumentDescriptor( "value", "Value", CommandArgumentKind.String, isOptional: true ) );
Assert.False( CommandArgumentParser.Parse( descriptor, Array.Empty<string>(), Array.Empty<CommandPlayer>() ).Arguments.Contains( "value" ) );
Assert.Equal( "", CommandArgumentParser.Parse( descriptor, new[] { "" }, Array.Empty<CommandPlayer>() ).Arguments.Get<string>( "value" ) );
Assert.False( CommandArgumentParser.Parse( descriptor, new[] { "one", "extra" }, Array.Empty<CommandPlayer>() ).IsSuccess );
Assert.False( CommandArgumentParser.Parse( Descriptor( Arg( CommandArgumentKind.String ) ), Array.Empty<string>(), Array.Empty<CommandPlayer>() ).IsSuccess );
}
[Fact]
public void NativeTokensAreNeverRetokenizedAndInputLimitsStillApply()
{
var descriptor = Descriptor( Arg( CommandArgumentKind.String ) );
var literal = "has \"quotes\" and spaces";
Assert.Equal( literal, CommandArgumentParser.Parse( descriptor, new[] { literal }, Array.Empty<CommandPlayer>() ).Arguments.Get<string>( "value" ) );
Assert.False( CommandArgumentParser.Parse( descriptor, new[] { new string( 'x', 513 ) }, Array.Empty<CommandPlayer>() ).IsSuccess );
Assert.False( CommandArgumentParser.Parse( descriptor, Enumerable.Repeat( "x", 65 ).ToArray(), Array.Empty<CommandPlayer>() ).IsSuccess );
Assert.False( CommandArgumentParser.Parse( descriptor, new string[] { null }, Array.Empty<CommandPlayer>() ).IsSuccess );
}
[Fact]
public void SyntaxReflectsRequiredOptionalRemainderAndConstraints()
{
var descriptor = Descriptor( new CommandArgumentDescriptor( "count", "How many", CommandArgumentKind.Integer, minimum: 1, maximum: 5 ),
new CommandArgumentDescriptor( "reason", "Why", CommandArgumentKind.String, isOptional: true, isRemainder: true ) );
var syntax = CommandSyntaxBuilder.Build( descriptor );
Assert.Equal( "test.parse <count> [reason...]", syntax.Syntax );
Assert.Equal( "test.use", syntax.Permission );
Assert.Contains( "minimum 1; maximum 5", syntax.Arguments[ 0 ] );
}
}