diff --git a/.editorconfig b/.editorconfig index 1b5926353..6a078547d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -66,12 +66,13 @@ csharp_style_var_for_built_in_types=true:silent csharp_style_var_when_type_is_apparent=true:silent csharp_style_var_elsewhere=true:silent # Expression-bodied members -csharp_style_expression_bodied_methods=false:silent +csharp_style_expression_bodied_methods=true:silent csharp_style_expression_bodied_constructors=false:silent -csharp_style_expression_bodied_operators=false:silent +csharp_style_expression_bodied_operators=true:silent csharp_style_expression_bodied_properties=true:silent csharp_style_expression_bodied_indexers=true:silent csharp_style_expression_bodied_accessors=true:silent +csharp_style_expression_bodied_local_functions = true:silent # Pattern matching preferences csharp_style_pattern_matching_over_is_with_cast_check=true:suggestion csharp_style_pattern_matching_over_as_with_null_check=true:suggestion diff --git a/src/TShock/Commands/CommandHandlerAttribute.cs b/src/TShock/Commands/CommandHandlerAttribute.cs index 4e5f39a21..444d1f1a5 100644 --- a/src/TShock/Commands/CommandHandlerAttribute.cs +++ b/src/TShock/Commands/CommandHandlerAttribute.cs @@ -16,37 +16,98 @@ // along with TShock. If not, see . using System; -using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; +using TShock.Utils; namespace TShock.Commands { /// - /// An attribute that can be applied to a method to indicate that it is a command handler. This can be applied to - /// a method mutiple times to provide aliasing. + /// Specifies that a method is a command handler. This controls many aspects of the command, and can be applied + /// multiple times to provide aliasing. /// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] + [MeansImplicitUse] public sealed class CommandHandlerAttribute : Attribute { + private readonly string _qualifiedName; + + private string? _helpText; + private string? _usageText; + private Type? _resourceType; + + /// + /// Gets the qualified name. This includes the namespace: e.g., tshock:kick. + /// + /// The qualified name. + public string QualifiedName => GetResourceStringMaybe(_qualifiedName); + + /// + /// Gets or sets the help text. This will show up in the /help command. If , then no help + /// text exists. + /// + /// The help text. + /// is . + [DisallowNull] + public string? HelpText { + get => GetResourceStringMaybe(_helpText); + set => _helpText = value ?? throw new ArgumentNullException(nameof(value)); + } + /// - /// Gets the command's name. This includes the command's namespace: e.g., "tshock:kick". + /// Gets or sets the usage text. This will show up in the /help command and when invalid syntax is used. If + /// , then no usage text exists. /// - public string CommandName { get; } + /// The usage text. + /// is . + [DisallowNull] + public string? UsageText { + get => GetResourceStringMaybe(_usageText); + set => _usageText = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Gets or sets the resource type to load localizable strings from. If , then no + /// localization will occur. + /// + /// The resource type to load localizable strings from. + /// is . + [DisallowNull] + public Type? ResourceType { + get => _resourceType; + set => _resourceType = value ?? throw new ArgumentNullException(nameof(value)); + } /// - /// Gets the command's sub-names. + /// Gets or sets a value indicating whether the command should be logged. /// - public IEnumerable CommandSubNames { get; } + /// if the command should be logged; otherwise, . + /// This property's value is useful for hiding, e.g., authentication commands. + public bool ShouldBeLogged { get; set; } = true; /// - /// Initializes a new instance of the class with the given command name - /// and sub-names. + /// Initializes a new instance of the class with the specified qualified + /// name. /// - /// The command name. - /// The command sub-names. + /// + /// The qualified name. This includes the namespace: e.g., tshock:kick. + /// /// - /// or are null. + /// is . /// - public CommandHandlerAttribute(string commandName, params string[] commandSubNames) { - CommandName = commandName ?? throw new ArgumentNullException(nameof(commandName)); - CommandSubNames = commandSubNames ?? throw new ArgumentNullException(nameof(commandSubNames)); + public CommandHandlerAttribute(string qualifiedName) { + if (qualifiedName is null) { + throw new ArgumentNullException(nameof(qualifiedName)); + } + + _qualifiedName = qualifiedName; + } + + [return: NotNullIfNotNull("str")] + private string? GetResourceStringMaybe(string? str) { + if (str is null) { + return null; + } + + return _resourceType != null ? ResourceHelper.LoadResource(_resourceType, str) : str; } } } diff --git a/src/TShock/Commands/ConsoleCommandSender.cs b/src/TShock/Commands/ConsoleCommandSender.cs new file mode 100644 index 000000000..71ce00348 --- /dev/null +++ b/src/TShock/Commands/ConsoleCommandSender.cs @@ -0,0 +1,107 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Globalization; +using System.Text; +using Destructurama; +using JetBrains.Annotations; +using Microsoft.Xna.Framework; +using Orion.Players; +using Serilog; +using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; + +namespace TShock.Commands { + /// + /// Represents a console-based command sender. + /// + public sealed class ConsoleCommandSender : ICommandSender { +#if DEBUG + private const LogEventLevel LogLevel = LogEventLevel.Verbose; +#else + private const LogEventLevel LogLevel = LogEventLevel.Error; +#endif + + /// + /// Gets the console-based command sender. + /// + /// The console-based command sender. + public static ConsoleCommandSender Instance { get; } = new ConsoleCommandSender(); + + /// + public string Name => "Console"; + + /// + public ILogger Log { get; } + + /// + public IPlayer? Player => null; + + [Pure] + private static string GetColorString(Color color) => $"\x1b[38;2;{color.R};{color.G};{color.B}m"; + + private ConsoleCommandSender() { + Log = new LoggerConfiguration() + .Destructure.UsingAttributes() + .WriteTo.Console( + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:l}{NewLine}{Exception}", + theme: AnsiConsoleTheme.Code) + .MinimumLevel.Is(LogLevel) + .CreateLogger(); + } + + /// + public void SendMessage(string message) => SendMessageImpl(message, string.Empty); + + /// + public void SendMessage(string message, Color color) => SendMessageImpl(message, GetColorString(color)); + + private static void SendMessageImpl(ReadOnlySpan message, string colorString) { + var output = new StringBuilder(message.Length); + while (true) { + output.Append(colorString); + var leftBracket = message.IndexOf('['); + var rightBracket = leftBracket + 1 + message[(leftBracket + 1)..].IndexOf(']'); + if (leftBracket < 0 || rightBracket < 0) { + break; + } + + output.Append(message[..leftBracket]); + var inside = message[(leftBracket + 1)..rightBracket]; + message = message[(rightBracket + 1)..]; + var colon = inside.IndexOf(':'); + var isValidColorTag = inside.StartsWith("c/", StringComparison.OrdinalIgnoreCase) && colon > 2; + if (!isValidColorTag) { + output.Append('[').Append(inside).Append(']'); + continue; + } + + if (int.TryParse(inside[2..colon], NumberStyles.AllowHexSpecifier, + CultureInfo.InvariantCulture, out var numberColor)) { + var tagColor = new Color((numberColor >> 16) & 255, (numberColor >> 8) & 255, numberColor & 255); + output.Append(GetColorString(tagColor)); + } + + output.Append(inside[(colon + 1)..]); + } + + output.Append(message).Append("\x1b[0m"); + Console.WriteLine(output); + } + } +} diff --git a/src/TShock/Commands/Exceptions/CommandExecuteException.cs b/src/TShock/Commands/Exceptions/CommandExecuteException.cs new file mode 100644 index 000000000..2d2b4c2cc --- /dev/null +++ b/src/TShock/Commands/Exceptions/CommandExecuteException.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace TShock.Commands.Exceptions { + /// + /// The exception thrown when a command could not be executed. + /// + [ExcludeFromCodeCoverage] + public class CommandExecuteException : Exception { + /// + /// Initializes a new instance of the class. + /// + public CommandExecuteException() { } + + /// + /// Initializes a new instance of the class with the specified message. + /// + /// The message. + public CommandExecuteException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with the specified message + /// and inner exception. + /// + /// The message. + /// The inner exception. + public CommandExecuteException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/TShock/Commands/Exceptions/CommandNotFoundException.cs b/src/TShock/Commands/Exceptions/CommandNotFoundException.cs new file mode 100644 index 000000000..5d58b2f25 --- /dev/null +++ b/src/TShock/Commands/Exceptions/CommandNotFoundException.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace TShock.Commands.Exceptions { + /// + /// The exception thrown when a command could not be found. + /// + [ExcludeFromCodeCoverage] + public class CommandNotFoundException : Exception { + /// + /// Initializes a new instance of the class. + /// + public CommandNotFoundException() { } + + /// + /// Initializes a new instance of the class with the specified message. + /// + /// The message. + public CommandNotFoundException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with the specified message + /// and inner exception. + /// + /// The message. + /// The inner exception. + public CommandNotFoundException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/TShock/Commands/Exceptions/CommandParseException.cs b/src/TShock/Commands/Exceptions/CommandParseException.cs new file mode 100644 index 000000000..31d9be897 --- /dev/null +++ b/src/TShock/Commands/Exceptions/CommandParseException.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace TShock.Commands.Exceptions { + /// + /// The exception thrown when a command input cannot be parsed. + /// + [ExcludeFromCodeCoverage] + public class CommandParseException : Exception { + /// + /// Initializes a new instance of the class. + /// + public CommandParseException() { } + + /// + /// Initializes a new instance of the class with the specified message. + /// + /// The message. + public CommandParseException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with the specified message and + /// inner exception. + /// + /// The message. + /// The inner exception. + public CommandParseException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/TShock/Commands/ICommand.cs b/src/TShock/Commands/ICommand.cs index ed63a1324..a052fdc7c 100644 --- a/src/TShock/Commands/ICommand.cs +++ b/src/TShock/Commands/ICommand.cs @@ -16,42 +16,48 @@ // along with TShock. If not, see . using System; -using System.Collections.Generic; -using System.Reflection; +using TShock.Commands.Exceptions; namespace TShock.Commands { /// - /// Represents a command. + /// Represents a command. Commands can be executed by command senders, and provide bits of functionality. + /// Implementations are thread-safe. /// public interface ICommand { /// - /// Gets the command's name. + /// Gets the command's qualified name. This includes the namespace: e.g., tshock:kick /// - string Name { get; } + /// The command's qualified name. + string QualifiedName { get; } /// - /// Gets the command's sub-names. + /// Gets the command's help text. This will show up in the /help command. /// - IEnumerable SubNames { get; } + /// The command's help text. + string HelpText { get; } /// - /// Gets the object associated with the command's handler. If null, then the command handler is static. + /// Gets the command's usage text. This will show up in the /help command and when invalid syntax is + /// used. /// - object? HandlerObject { get; } + /// The command's usage text. + string UsageText { get; } /// - /// Gets the command's handler. + /// Gets a value indicating whether the command should be logged. /// - MethodBase Handler { get; } + /// if the command should be logged; otherwise . + /// This property's value is useful for hiding, e.g., authentication commands. + bool ShouldBeLogged { get; } /// - /// Invokes the command as the given sender with the specified input. + /// Invokes the command as a with the specified . /// /// The sender. - /// The input. This does not include the command's name or sub-names. - /// - /// or are null. - /// + /// The input. This does not include the command's name. + /// is . + /// The command could not be executed. + /// The command input could not be parsed. void Invoke(ICommandSender sender, string input); } } diff --git a/src/TShock/Commands/ICommandSender.cs b/src/TShock/Commands/ICommandSender.cs index 67cf0293e..c923976e1 100644 --- a/src/TShock/Commands/ICommandSender.cs +++ b/src/TShock/Commands/ICommandSender.cs @@ -17,6 +17,7 @@ using System; using Microsoft.Xna.Framework; +using Orion.Players; using Serilog; namespace TShock.Commands { @@ -27,19 +28,87 @@ public interface ICommandSender { /// /// Gets the sender's name. /// + /// The sender's name. string Name { get; } /// /// Gets the sender's log. /// + /// The sender's log. + /// + /// This property's value can be used to log information to the sender. For example, you can pass debugging or + /// error messages and they will be formatted appropriately. + /// ILogger Log { get; } /// - /// Sends a message to the sender with the given color. + /// Gets the sender's player. If , then there is no associated player. + /// + /// The sender's player. + /// + /// This property's value can be used to modify the command sender's state. For example, you can heal or kill + /// the player. + /// + IPlayer? Player { get; } + + /// + /// Sends a to the sender. + /// + /// The message. + /// is . + void SendMessage(string message); + + /// + /// Sends a to the sender with the given . /// /// The message. /// The color. - /// is null. + /// is . void SendMessage(string message, Color color); } + + /// + /// Provides extensions for the interface. + /// + public static class CommandSenderExtensions { + /// + /// Sends an error to the . + /// + /// The sender. + /// The error message. + /// + /// or are . + /// + public static void SendErrorMessage(this ICommandSender sender, string message) { + if (sender is null) { + throw new ArgumentNullException(nameof(sender)); + } + + if (message is null) { + throw new ArgumentNullException(nameof(message)); + } + + sender.SendMessage(message, new Color(0xcc, 0x44, 0x44)); + } + + /// + /// Sends an informational to the . + /// + /// The sender. + /// The informational message. + /// + /// or are . + /// + public static void SendInfoMessage(this ICommandSender sender, string message) { + if (sender is null) { + throw new ArgumentNullException(nameof(sender)); + } + + if (message is null) { + throw new ArgumentNullException(nameof(message)); + } + + sender.SendMessage(message, new Color(0xff, 0xf0, 0x14)); + } + } } diff --git a/src/TShock/Commands/ICommandService.cs b/src/TShock/Commands/ICommandService.cs index 4d0d1e53a..e78e8313f 100644 --- a/src/TShock/Commands/ICommandService.cs +++ b/src/TShock/Commands/ICommandService.cs @@ -17,66 +17,53 @@ using System; using System.Collections.Generic; -using Orion; -using Orion.Events; -using TShock.Events.Commands; +using TShock.Commands.Parsers; namespace TShock.Commands { /// - /// Represents a service that manages commands. Provides command-related hooks and methods. + /// Represents a service that manages commands. Provides command-related properties and methods. /// - public interface ICommandService : IService { + public interface ICommandService { /// - /// Gets or sets the event handlers that occur when registering a command. + /// Gets a read-only mapping from qualified command names to commands. /// - EventHandlerCollection? CommandRegister { get; set; } + /// A read-only mapping from qualified command names to commands. + IReadOnlyDictionary Commands { get; } /// - /// Gets or sets the event handlers that occur when executing a command. + /// Gets a read-only mapping from types to parsers. /// - EventHandlerCollection? CommandExecute { get; set; } + /// A read-only mapping from types to parsers. + IReadOnlyDictionary Parsers { get; } /// - /// Gets or sets the event handlers that occur when unregistering a command. + /// Registers and returns the commands defined with the 's command handlers. + /// Command handlers are specified using the attribute. /// - EventHandlerCollection? CommandUnregister { get; set; } - - /// - /// Registers and returns the commands defined with the given object's command handlers. - /// - /// The object. - /// The resulting commands. - /// is null. - IReadOnlyCollection RegisterCommands(object obj); - - /// - /// Registers and returns the commands defined with the given type's static command handlers. - /// - /// The type. + /// The object. /// The resulting commands. - /// is null. - IReadOnlyCollection RegisterCommands(Type type); + /// + /// is . + /// + IReadOnlyCollection RegisterCommands(object handlerObject); /// - /// Returns all commands with the given command name and sub-names. + /// Registers as the parser for . This will allow + /// to be parsed in command handlers. /// - /// The command name. - /// The command sub-names. - /// The commands with the given command name. - /// - /// An element of is null. - /// - /// - /// or are null. - /// - IReadOnlyCollection FindCommands(string commandName, params string[] commandSubNames); + /// The parse type. + /// The parser. + /// is . + void RegisterParser(IArgumentParser parser); /// - /// Unregisters the given command and returns a value indicating success. + /// Unregisters the and returns a value indicating success. /// /// The command. - /// A value indicating whether the command was successfully unregistered. - /// is null. + /// + /// if was unregistered; otherwise, . + /// + /// is . bool UnregisterCommand(ICommand command); } } diff --git a/src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs b/src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs new file mode 100644 index 000000000..12ee5ae2f --- /dev/null +++ b/src/TShock/Commands/Parsers/Attributes/FlagAttribute.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace TShock.Commands.Parsers.Attributes { + /// + /// Specifies that a parameter should have flag-based parsing. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class FlagAttribute : Attribute { + /// + /// Gets a read-only view of the flags. Flags with length 1 are treated as short flags. + /// + /// A read-only view of the flags. + public IReadOnlyCollection Flags { get; } + + /// + /// Initializes a new instance of the class with the specified flags. Flags with + /// length 1 are treated as short flags. + /// + /// The flag. + /// The alternate flags. This may be empty. + /// + /// contains a element. + /// + /// + /// or are . + /// + [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", + Justification = "strings are not user-facing")] + public FlagAttribute(string flag, params string[] alternateFlags) { + if (flag is null) { + throw new ArgumentNullException(nameof(flag)); + } + + if (alternateFlags is null) { + throw new ArgumentNullException(nameof(alternateFlags)); + } + + if (alternateFlags.Any(f => f == null)) { + throw new ArgumentException("Array contains null element.", nameof(alternateFlags)); + } + + Flags = new[] { flag }.Concat(alternateFlags).ToList(); + } + } +} diff --git a/src/TShock/Commands/Parsers/Attributes/RestOfInputAttribute.cs b/src/TShock/Commands/Parsers/Attributes/RestOfInputAttribute.cs new file mode 100644 index 000000000..f0433aee2 --- /dev/null +++ b/src/TShock/Commands/Parsers/Attributes/RestOfInputAttribute.cs @@ -0,0 +1,26 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; + +namespace TShock.Commands.Parsers.Attributes { + /// + /// Specifies that a parameter should parse to the rest of the input. + /// + [AttributeUsage(AttributeTargets.Parameter)] + public sealed class RestOfInputAttribute : Attribute { } +} diff --git a/src/TShock/Commands/Parsers/DoubleParser.cs b/src/TShock/Commands/Parsers/DoubleParser.cs new file mode 100644 index 000000000..4e1abf715 --- /dev/null +++ b/src/TShock/Commands/Parsers/DoubleParser.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using TShock.Commands.Exceptions; +using TShock.Properties; +using TShock.Utils.Extensions; + +namespace TShock.Commands.Parsers { + /// + /// Parses a Double. + /// + public sealed class DoubleParser : IArgumentParser { + /// + public double Parse(ref ReadOnlySpan input, ISet attributes) { + var end = input.IndexOfOrEnd(' '); + var parse = input[..end]; + + // Calling Parse here instead of TryParse allows us to give better error messages. + try { + var value = double.Parse(parse, NumberStyles.Float, CultureInfo.InvariantCulture); + input = input[end..]; + return value; + } catch (FormatException ex) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.DoubleParser_InvalidDouble, + parse.ToString()), ex); + } + } + } +} diff --git a/src/TShock/Commands/Parsers/IArgumentParser.cs b/src/TShock/Commands/Parsers/IArgumentParser.cs new file mode 100644 index 000000000..59afd2932 --- /dev/null +++ b/src/TShock/Commands/Parsers/IArgumentParser.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using TShock.Commands.Exceptions; + +namespace TShock.Commands.Parsers { + /// + /// Provides parsing support. + /// + public interface IArgumentParser { + /// + /// Parses the and returns a corresponding object using the specified + /// . will be consumed as necessary. + /// + /// The input. This is guaranteed to start with a non-whitespace character. + /// The attributes. + /// A corresponding object. + /// The input could not be parsed properly. + object? Parse(ref ReadOnlySpan input, ISet attributes); + } + + /// + /// Provides type-safe parsing support. + /// + /// The parse type. + public interface IArgumentParser : IArgumentParser { + /// + /// Parses the and returns a corresponding instance using + /// the specified . will be consumed as necessary. + /// + /// The input. This is guaranteed to start with a non-whitespace character. + /// The attributes. + /// A corresponding instance. + /// The input could not be parsed properly. + new TParse Parse(ref ReadOnlySpan input, ISet attributes); + + [ExcludeFromCodeCoverage] + object? IArgumentParser.Parse(ref ReadOnlySpan input, ISet attributes) => + Parse(ref input, attributes); + } +} diff --git a/src/TShock/Commands/Parsers/Int32Parser.cs b/src/TShock/Commands/Parsers/Int32Parser.cs new file mode 100644 index 000000000..4e5755f98 --- /dev/null +++ b/src/TShock/Commands/Parsers/Int32Parser.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using TShock.Commands.Exceptions; +using TShock.Properties; +using TShock.Utils.Extensions; + +namespace TShock.Commands.Parsers { + /// + /// Parses an Int32. + /// + public sealed class Int32Parser : IArgumentParser { + /// + public int Parse(ref ReadOnlySpan input, ISet attributes) { + var end = input.IndexOfOrEnd(' '); + var parse = input[..end]; + + // Calling Parse here instead of TryParse allows us to give better error messages. + try { + var value = int.Parse(parse, NumberStyles.Integer, CultureInfo.InvariantCulture); + input = input[end..]; + return value; + } catch (FormatException ex) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.Int32Parser_InvalidInteger, + parse.ToString()), ex); + } catch (OverflowException ex) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.Int32Parser_IntegerOutOfRange, + parse.ToString()), ex); + } + } + } +} diff --git a/src/TShock/Commands/Parsers/StringParser.cs b/src/TShock/Commands/Parsers/StringParser.cs new file mode 100644 index 000000000..cb48057fb --- /dev/null +++ b/src/TShock/Commands/Parsers/StringParser.cs @@ -0,0 +1,93 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using TShock.Commands.Exceptions; +using TShock.Commands.Parsers.Attributes; +using TShock.Properties; + +namespace TShock.Commands.Parsers { + /// + /// Parses a string. + /// + public sealed class StringParser : IArgumentParser { + /// + public string Parse(ref ReadOnlySpan input, ISet attributes) { + if (attributes.Any(a => a is RestOfInputAttribute)) { + var result = input.ToString(); + input = default; + return result; + } + + // Begin building our string character-by-character. + var builder = new StringBuilder(); + var end = 0; + var isInQuotes = false; + while (end < input.Length) { + var c = input[end]; + + // Handle quotes. + if (c == '"') { + ++end; + if (isInQuotes) { + break; + } + + isInQuotes = true; + continue; + } + + // Handle escape characters. + if (c == '\\') { + if (++end >= input.Length) { + throw new CommandParseException(Resources.StringParser_InvalidBackslash); + } + + var nextC = input[end]; + if (nextC == '"' || nextC == '\\' || char.IsWhiteSpace(nextC)) { + builder.Append(nextC); + } else if (nextC == 't') { + builder.Append('\t'); + } else if (nextC == 'n') { + builder.Append('\n'); + } else { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.StringParser_UnrecognizedEscape, + nextC)); + } + + ++end; + continue; + } + + if (char.IsWhiteSpace(c) && !isInQuotes) { + break; + } + + builder.Append(c); + ++end; + } + + input = input[end..]; + return builder.ToString(); + } + } +} diff --git a/src/TShock/Commands/PlayerCommandSender.cs b/src/TShock/Commands/PlayerCommandSender.cs new file mode 100644 index 000000000..63087e370 --- /dev/null +++ b/src/TShock/Commands/PlayerCommandSender.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using Destructurama; +using Microsoft.Xna.Framework; +using Orion.Players; +using Serilog; +using Serilog.Events; +using TShock.Logging; + +namespace TShock.Commands { + /// + /// Represents a player-based command sender. + /// + public sealed class PlayerCommandSender : ICommandSender { +#if DEBUG + private const LogEventLevel LogLevel = LogEventLevel.Verbose; +#else + private const LogEventLevel LogLevel = LogEventLevel.Error; +#endif + + /// + public string Name => Player.Name; + + /// + public ILogger Log { get; } + + /// + public IPlayer Player { get; } + + /// + /// Initializes a new instance of the class with the specified player. + /// + /// The player. + /// is . + public PlayerCommandSender(IPlayer player) { + Player = player ?? throw new ArgumentNullException(nameof(player)); + Log = new LoggerConfiguration() + .Destructure.UsingAttributes() + .MinimumLevel.Is(LogLevel) + .WriteTo.Player(player) + .CreateLogger(); + } + + /// + public void SendMessage(string message) => SendMessage(message, Color.White); + + /// + public void SendMessage(string message, Color color) => Player.SendMessage(message, color); + } +} diff --git a/src/TShock/Commands/TShockCommand.cs b/src/TShock/Commands/TShockCommand.cs new file mode 100644 index 000000000..708509c97 --- /dev/null +++ b/src/TShock/Commands/TShockCommand.cs @@ -0,0 +1,271 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Reflection; +using Orion.Events; +using TShock.Commands.Exceptions; +using TShock.Commands.Parsers.Attributes; +using TShock.Events.Commands; +using TShock.Properties; +using TShock.Utils.Extensions; + +namespace TShock.Commands { + internal class TShockCommand : ICommand { + private readonly TShockCommandService _commandService; + private readonly object _handlerObject; + private readonly MethodInfo _handler; + private readonly CommandHandlerAttribute _attribute; + + private readonly ISet _validShortFlags = new HashSet(); + private readonly ISet _validLongFlags = new HashSet(); + private readonly IDictionary _validOptionals = new Dictionary(); + + public string QualifiedName => _attribute.QualifiedName; + public string HelpText => _attribute.HelpText ?? Resources.Command_MissingHelpText; + public string UsageText => _attribute.UsageText ?? Resources.Command_MissingUsageText; + public bool ShouldBeLogged => _attribute.ShouldBeLogged; + + // We need to inject TShockCommandService so that we can raise a CommandExecuteEvent. + public TShockCommand( + TShockCommandService commandService, object handlerObject, MethodInfo handler, + CommandHandlerAttribute attribute) { + Debug.Assert(commandService != null, "command service should not be null"); + Debug.Assert(handlerObject != null, "handler object should not be null"); + Debug.Assert(handler != null, "handler should not be null"); + Debug.Assert(attribute != null, "attribute should not be null"); + + _commandService = commandService; + _handlerObject = handlerObject; + _handler = handler; + _attribute = attribute; + + // Preprocessing parameters in the constructor allows us to learn the command's flags and optionals. + void PreprocessParameter(ParameterInfo parameterInfo) { + var parameterType = parameterInfo.ParameterType; + + // If the parameter is a bool and it is marked with FlagAttribute, we'll note it. + if (parameterType == typeof(bool)) { + var attribute = parameterInfo.GetCustomAttribute(); + foreach (var flag in attribute?.Flags ?? Enumerable.Empty()) { + if (flag.Length == 1) { + _validShortFlags.Add(flag[0]); + } else { + _validLongFlags.Add(flag); + } + } + } + + // If the parameter is optional, we'll note it. We replace underscores with hyphens here since hyphens + // aren't valid in C# identifiers. + if (parameterInfo.IsOptional) { + _validOptionals.Add(parameterInfo.Name.Replace('_', '-'), parameterInfo); + } + } + + foreach (var parameterInfo in _handler.GetParameters()) { + PreprocessParameter(parameterInfo); + } + } + + public void Invoke(ICommandSender sender, string input) { + if (sender is null) { + throw new ArgumentNullException(nameof(sender)); + } + + var e = new CommandExecuteEvent(this, sender, input); + _commandService.Kernel.RaiseEvent(e, _commandService.Log); + if (e.IsCanceled()) { + return; + } + + var shortFlags = new HashSet(); + var longFlags = new HashSet(); + var optionals = new Dictionary(); + + object? ParseArgument(ref ReadOnlySpan input, ParameterInfo parameterInfo, Type? typeHint = null) { + var parameterType = typeHint ?? parameterInfo.ParameterType; + if (!_commandService.Parsers.TryGetValue(parameterType, out var parser)) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedArgType, + parameterType)); + } + + var attributes = parameterInfo.GetCustomAttributes().ToHashSet(); + if (input.IsEmpty) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_MissingArg, + parameterInfo)); + } + + return parser.Parse(ref input, attributes); + } + + void ParseShortFlags(ref ReadOnlySpan input, int space) { + if (space <= 1) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + } + + foreach (var c in input[1..space]) { + if (!_validShortFlags.Contains(c)) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedShortFlag, + c)); + } + + shortFlags.Add(c); + } + + input = input[space..]; + } + + void ParseLongFlag(ref ReadOnlySpan input, int space) { + if (space <= 2) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + } + + var longFlag = input[2..space].ToString(); + if (!_validLongFlags.Contains(longFlag)) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedLongFlag, + longFlag)); + } + + longFlags.Add(longFlag); + input = input[space..]; + } + + void ParseOptional(ref ReadOnlySpan input, int equals) { + if (equals <= 2) { + throw new CommandParseException(Resources.CommandParse_InvalidHyphenatedArg); + } + + var optional = input[2..equals].ToString(); + if (!_validOptionals.TryGetValue(optional, out var parameterInfo)) { + throw new CommandParseException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandParse_UnrecognizedOptional, + optional)); + } + + input = input[(equals + 1)..]; + input = input.TrimStart(); + optionals[optional] = ParseArgument(ref input, parameterInfo); + } + + // Parse all hyphenated arguments: + // 1) Short flags are single-character flags and use one hyphen: "-f". + // 2) Long flags are string flags and use two hyphens: "--force". + // 3) Optionals specify values with two hyphens: "--depth=10". + void ParseHyphenatedArguments(ref ReadOnlySpan input) { + input = input.TrimStart(); + while (input.StartsWith("-")) { + var space = input.IndexOfOrEnd(' '); + if (input.StartsWith("--")) { + var equals = input.IndexOfOrEnd('='); + if (equals < space) { + ParseOptional(ref input, equals); + } else { + ParseLongFlag(ref input, space); + } + } else { + ParseShortFlags(ref input, space); + } + + input = input.TrimStart(); + } + } + + // Parse a parameter: + // 1) If the parameter is an ICommandSender, then inject sender. + // 2) If the parameter is a bool and is marked with FlagAttribute, then inject the flag. + // 3) If the parameter is optional, then inject the optional or else the default value. + // 4) Otherwise, we parse the argument directly. + object? ParseParameter(ParameterInfo parameterInfo, ref ReadOnlySpan input) { + var parameterType = parameterInfo.ParameterType; + if (parameterType == typeof(ICommandSender)) { + return sender; + } + + if (parameterType == typeof(bool)) { + var attribute = parameterInfo.GetCustomAttribute(); + if (attribute != null) { + return attribute.Flags.Any( + f => f.Length == 1 && shortFlags.Contains(f[0]) || longFlags.Contains(f)); + } + } + + if (parameterInfo.GetCustomAttribute() != null) { + var elementType = parameterType.GetElementType(); + + var list = new List(); + input = input.TrimStart(); + while (!input.IsEmpty) { + list.Add(ParseArgument(ref input, parameterInfo, elementType)); + input = input.TrimStart(); + } + + var array = Array.CreateInstance(elementType, list.Count); + for (var i = 0; i < list.Count; ++i) { + array.SetValue(list[i], i); + } + + return array; + } + + input = input.TrimStart(); + if (parameterInfo.IsOptional) { + var optional = parameterInfo.Name.Replace('_', '-'); + if (optionals.TryGetValue(optional, out var value)) { + return value; + } + + if (input.IsEmpty) { + return parameterInfo.DefaultValue; + } + } + + return ParseArgument(ref input, parameterInfo); + } + + var inputSpan = e.Input.AsSpan(); + if (_validShortFlags.Count + _validLongFlags.Count + _validOptionals.Count > 0) { + ParseHyphenatedArguments(ref inputSpan); + } + + var parameterInfos = _handler.GetParameters(); + var parameters = new object?[parameterInfos.Length]; + for (var i = 0; i < parameters.Length; ++i) { + parameters[i] = ParseParameter(parameterInfos[i], ref inputSpan); + } + + // Ensure that we've consumed all of the useful parts of the input. + if (!inputSpan.IsWhiteSpace()) { + throw new CommandParseException(Resources.CommandParse_TooManyArgs); + } + + try { + _handler.Invoke(_handlerObject, parameters); + } catch (TargetInvocationException ex) { + throw new CommandExecuteException(Resources.CommandExecute_Exception, ex.InnerException); + } + } + } +} diff --git a/src/TShock/Commands/TShockCommandService.cs b/src/TShock/Commands/TShockCommandService.cs new file mode 100644 index 000000000..d83e68d2a --- /dev/null +++ b/src/TShock/Commands/TShockCommandService.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using Orion; +using Orion.Events; +using Serilog; +using TShock.Commands.Parsers; +using TShock.Events.Commands; + +namespace TShock.Commands { + internal sealed class TShockCommandService : OrionService, ICommandService { + private readonly Dictionary _commands = new Dictionary(); + private readonly Dictionary _parsers = new Dictionary(); + + public IReadOnlyDictionary Commands => _commands; + public IReadOnlyDictionary Parsers => _parsers; + + public TShockCommandService(OrionKernel kernel, ILogger log) : base(kernel, log) { + Debug.Assert(log != null, "log should not be null"); + } + + public IReadOnlyCollection RegisterCommands(object handlerObject) { + if (handlerObject is null) { + throw new ArgumentNullException(nameof(handlerObject)); + } + + ICommand? RegisterCommand(ICommand command) { + var e = new CommandRegisterEvent(command); + Kernel.RaiseEvent(e, Log); + if (e.IsCanceled()) { + return null; + } + + _commands.Add(command.QualifiedName, command); + return command; + } + + return handlerObject.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance) + .SelectMany(m => m.GetCustomAttributes(), + (m, a) => RegisterCommand(new TShockCommand(this, handlerObject, m, a))) + .Where(c => c != null).ToList()!; + } + + public void RegisterParser(IArgumentParser parser) => + _parsers[typeof(TParse)] = parser ?? throw new ArgumentNullException(nameof(parser)); + + public bool UnregisterCommand(ICommand command) { + if (command is null) { + throw new ArgumentNullException(nameof(command)); + } + + var e = new CommandUnregisterEvent(command); + Kernel.RaiseEvent(e, Log); + return !e.IsCanceled() && _commands.Remove(command.QualifiedName); + } + } +} diff --git a/src/TShock/Events/Commands/CommandEvent.cs b/src/TShock/Events/Commands/CommandEvent.cs new file mode 100644 index 000000000..277da4214 --- /dev/null +++ b/src/TShock/Events/Commands/CommandEvent.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using Orion.Events; +using TShock.Commands; + +namespace TShock.Events.Commands { + /// + /// Represents a command-related event. + /// + public abstract class CommandEvent : Event { + private ICommand _command; + + /// + /// Gets or sets the command. + /// + /// The command. + /// is . + public ICommand Command { + get => _command; + set => _command = value ?? throw new ArgumentNullException(nameof(value)); + } + + /// + /// Initializes a new instance of the class with the specified command. + /// + /// The command. + /// is . + protected CommandEvent(ICommand command) { + _command = command ?? throw new ArgumentNullException(nameof(command)); + } + } +} diff --git a/src/TShock/Events/Commands/CommandExecuteEventArgs.cs b/src/TShock/Events/Commands/CommandExecuteEvent.cs similarity index 64% rename from src/TShock/Events/Commands/CommandExecuteEventArgs.cs rename to src/TShock/Events/Commands/CommandExecuteEvent.cs index 1d531befd..875835699 100644 --- a/src/TShock/Events/Commands/CommandExecuteEventArgs.cs +++ b/src/TShock/Events/Commands/CommandExecuteEvent.cs @@ -16,38 +16,54 @@ // along with TShock. If not, see . using System; +using Destructurama.Attributed; +using Orion.Events; +using Orion.Utils; using TShock.Commands; namespace TShock.Events.Commands { /// - /// Provides data for the event. + /// An event that occurs when a command executes. This event can be canceled and modified. /// - public sealed class CommandExecuteEventArgs : CommandEventArgs { + [Event("command-execute")] + public sealed class CommandExecuteEvent : CommandEvent, ICancelable, IDirtiable { private string _input; + /// + [NotLogged] + public string? CancellationReason { get; set; } + + /// + [NotLogged] + public bool IsDirty { get; private set; } + /// /// Gets the command sender. /// public ICommandSender Sender { get; } /// - /// Gets or sets the input. + /// Gets or sets the input. This does not include the command's name. /// + /// is . public string Input { get => _input; set => _input = value ?? throw new ArgumentNullException(nameof(value)); } /// - /// Initializes a new instance of the class with the specified command, + /// Initializes a new instance of the class with the specified command, /// command sender, and input. /// /// The command. /// The command sender. - /// The input. This does not include the command's name or sub-names. - public CommandExecuteEventArgs(ICommand command, ICommandSender sender, string input) : base(command) { + /// The input. This does not include the command's name. + public CommandExecuteEvent(ICommand command, ICommandSender sender, string input) : base(command) { Sender = sender ?? throw new ArgumentNullException(nameof(sender)); _input = input ?? throw new ArgumentNullException(nameof(input)); } + + /// + public void Clean() => IsDirty = false; } } diff --git a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs b/src/TShock/Events/Commands/CommandRegisterEvent.cs similarity index 67% rename from src/TShock/Events/Commands/CommandRegisterEventArgs.cs rename to src/TShock/Events/Commands/CommandRegisterEvent.cs index 766bbd6b1..21cc97b8c 100644 --- a/src/TShock/Events/Commands/CommandRegisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandRegisterEvent.cs @@ -16,18 +16,25 @@ // along with TShock. If not, see . using System; +using Destructurama.Attributed; +using Orion.Events; using TShock.Commands; namespace TShock.Events.Commands { /// - /// Provides data for the event. + /// An event that occurs when a command is registered. This event can be canceled. /// - public sealed class CommandRegisterEventArgs : CommandEventArgs { + [Event("command-register")] + public sealed class CommandRegisterEvent : CommandEvent, ICancelable { + /// + [NotLogged] + public string? CancellationReason { get; set; } + /// - /// Initializes a new instance of the class with the specified command. + /// Initializes a new instance of the class with the specified command. /// /// The command. - /// is null. - public CommandRegisterEventArgs(ICommand command) : base(command) { } + /// is . + public CommandRegisterEvent(ICommand command) : base(command) { } } } diff --git a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs b/src/TShock/Events/Commands/CommandUnregisterEvent.cs similarity index 66% rename from src/TShock/Events/Commands/CommandUnregisterEventArgs.cs rename to src/TShock/Events/Commands/CommandUnregisterEvent.cs index 02f54b9c9..1e6c345dd 100644 --- a/src/TShock/Events/Commands/CommandUnregisterEventArgs.cs +++ b/src/TShock/Events/Commands/CommandUnregisterEvent.cs @@ -16,18 +16,25 @@ // along with TShock. If not, see . using System; +using Destructurama.Attributed; +using Orion.Events; using TShock.Commands; namespace TShock.Events.Commands { /// - /// Provides data for the event. + /// An event that occurs when a command is unregistered. This event can be canceled. /// - public sealed class CommandUnregisterEventArgs : CommandEventArgs { + [Event("command-unregister")] + public sealed class CommandUnregisterEvent : CommandEvent, ICancelable { + /// + [NotLogged] + public string? CancellationReason { get; set; } + /// - /// Initializes a new instance of the class with the specified command. + /// Initializes a new instance of the class with the specified command. /// /// The command. - /// is null. - public CommandUnregisterEventArgs(ICommand command) : base(command) { } + /// is . + public CommandUnregisterEvent(ICommand command) : base(command) { } } } diff --git a/src/TShock/Logging/Formatting/ExceptionFormatter.cs b/src/TShock/Logging/Formatting/ExceptionFormatter.cs new file mode 100644 index 000000000..ee8c65c33 --- /dev/null +++ b/src/TShock/Logging/Formatting/ExceptionFormatter.cs @@ -0,0 +1,46 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class ExceptionFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + + public ExceptionFormatter(PlayerLogTheme theme) { + Debug.Assert(theme != null, "theme should not be null"); + + _theme = theme; + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + var exception = logEvent.Exception; + if (exception is null) { + return; + } + + output.Write(_theme.Stylize(exception.ToString(), PlayerLogThemeStyle.Exception)); + } + } +} diff --git a/src/TShock/Logging/Formatting/LevelFormatter.cs b/src/TShock/Logging/Formatting/LevelFormatter.cs new file mode 100644 index 000000000..f62f22a3c --- /dev/null +++ b/src/TShock/Logging/Formatting/LevelFormatter.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using TShock.Logging.Themes; +using TShock.Utils.Extensions; + +namespace TShock.Logging.Formatting { + internal sealed class LevelFormatter : ITextFormatter { + private static readonly IDictionary _levelToText = + new Dictionary { + [LogEventLevel.Verbose] = "VRB", + [LogEventLevel.Debug] = "DBG", + [LogEventLevel.Information] = "INF", + [LogEventLevel.Warning] = "WRN", + [LogEventLevel.Error] = "ERR", + [LogEventLevel.Fatal] = "FTL" + }; + + private static readonly IDictionary _levelToStyle = + new Dictionary { + [LogEventLevel.Verbose] = PlayerLogThemeStyle.VerboseLevel, + [LogEventLevel.Debug] = PlayerLogThemeStyle.DebugLevel, + [LogEventLevel.Information] = PlayerLogThemeStyle.InformationLevel, + [LogEventLevel.Warning] = PlayerLogThemeStyle.WarningLevel, + [LogEventLevel.Error] = PlayerLogThemeStyle.ErrorLevel, + [LogEventLevel.Fatal] = PlayerLogThemeStyle.FatalLevel + }; + + private readonly PlayerLogTheme _theme; + + public LevelFormatter(PlayerLogTheme theme) { + Debug.Assert(theme != null, "theme should not be null"); + + _theme = theme; + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + var level = logEvent.Level; + var text = _levelToText.GetValueOrDefault(level, () => "???"); + var style = _levelToStyle.GetValueOrDefault(level, () => PlayerLogThemeStyle.Invalid); + output.Write(_theme.Stylize(text, style)); + } + } +} diff --git a/src/TShock/Logging/Formatting/MessageTemplateFormatter.cs b/src/TShock/Logging/Formatting/MessageTemplateFormatter.cs new file mode 100644 index 000000000..0bf34d986 --- /dev/null +++ b/src/TShock/Logging/Formatting/MessageTemplateFormatter.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Parsing; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class MessageTemplateFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + private readonly PlayerLogValueVisitor _visitor; + + public MessageTemplateFormatter(PlayerLogTheme theme, IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + + _theme = theme; + _visitor = new PlayerLogValueVisitor(theme, formatProvider); + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + foreach (var token in logEvent.MessageTemplate.Tokens) { + if (token is TextToken textToken) { + output.Write(_theme.Stylize(textToken.Text, PlayerLogThemeStyle.Text)); + continue; + } + + var propertyToken = (PropertyToken)token; + if (!logEvent.Properties.TryGetValue(propertyToken.PropertyName, out var propertyValue)) { + output.Write(_theme.Stylize(propertyToken.ToString(), PlayerLogThemeStyle.Invalid)); + continue; + } + + _visitor.Format(propertyValue, output); + } + } + } +} diff --git a/src/TShock/Logging/Formatting/NewLineFormatter.cs b/src/TShock/Logging/Formatting/NewLineFormatter.cs new file mode 100644 index 000000000..a79b9c7ea --- /dev/null +++ b/src/TShock/Logging/Formatting/NewLineFormatter.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; + +namespace TShock.Logging.Formatting { + internal sealed class NewLineFormatter : ITextFormatter { + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + output.Write('\n'); + } + } +} diff --git a/src/TShock/Logging/Formatting/PlayerLogFormatter.cs b/src/TShock/Logging/Formatting/PlayerLogFormatter.cs new file mode 100644 index 000000000..075624619 --- /dev/null +++ b/src/TShock/Logging/Formatting/PlayerLogFormatter.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Formatting.Display; +using Serilog.Parsing; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class PlayerLogFormatter : ITextFormatter { + // Cache the MessageTemplateParser because why not? + private static readonly MessageTemplateParser _templateParser = new MessageTemplateParser(); + + private readonly IList _formatters = new List(); + + public PlayerLogFormatter(PlayerLogTheme theme, string outputTemplate, IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(outputTemplate != null, "output template should not be null"); + + // The general strategy is to parse the template, and create a list of formatters in the same order as the + // properties found within the template. We can then run the formatters in order on each log event. + var template = _templateParser.Parse(outputTemplate); + foreach (var token in template.Tokens) { + if (token is TextToken textToken) { + _formatters.Add(new TextFormatter(theme, textToken.Text)); + continue; + } + + var propertyToken = (PropertyToken)token; + _formatters.Add(propertyToken.PropertyName switch { + OutputProperties.MessagePropertyName => new MessageTemplateFormatter(theme, formatProvider), + OutputProperties.TimestampPropertyName => + new TimestampFormatter(theme, propertyToken.Format, formatProvider), + OutputProperties.LevelPropertyName => new LevelFormatter(theme), + OutputProperties.NewLinePropertyName => new NewLineFormatter(), + OutputProperties.ExceptionPropertyName => new ExceptionFormatter(theme), + OutputProperties.PropertiesPropertyName => new PropertiesFormatter(theme, template, formatProvider), + _ => new PropertyFormatter(theme, propertyToken, formatProvider) + }); + } + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + foreach (var formatter in _formatters) { + formatter.Format(logEvent, output); + } + } + } +} diff --git a/src/TShock/Logging/Formatting/PropertiesFormatter.cs b/src/TShock/Logging/Formatting/PropertiesFormatter.cs new file mode 100644 index 000000000..2ead9beef --- /dev/null +++ b/src/TShock/Logging/Formatting/PropertiesFormatter.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Parsing; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class PropertiesFormatter : ITextFormatter { + private readonly MessageTemplate _outputTemplate; + private readonly PlayerLogValueVisitor _visitor; + + public PropertiesFormatter(PlayerLogTheme theme, MessageTemplate outputTemplate, + IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(outputTemplate != null, "output template should not be null"); + + _outputTemplate = outputTemplate; + _visitor = new PlayerLogValueVisitor(theme, formatProvider); + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + bool IsExcluded(string propertyName) => _outputTemplate.Tokens + .Concat(logEvent.MessageTemplate.Tokens) + .All(t => !(t is PropertyToken p) || p.PropertyName != propertyName); + + var properties = logEvent.Properties + .Where(kvp => IsExcluded(kvp.Key)) + .Select(kvp => new LogEventProperty(kvp.Key, kvp.Value)); + _visitor.Format(new StructureValue(properties), output); + } + } +} diff --git a/src/TShock/Logging/Formatting/PropertyFormatter.cs b/src/TShock/Logging/Formatting/PropertyFormatter.cs new file mode 100644 index 000000000..b8f19f9ba --- /dev/null +++ b/src/TShock/Logging/Formatting/PropertyFormatter.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using Serilog.Parsing; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class PropertyFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + private readonly PropertyToken _propertyToken; + private readonly PlayerLogValueVisitor _visitor; + + public PropertyFormatter(PlayerLogTheme theme, PropertyToken propertyToken, IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(propertyToken != null, "property token should not be null"); + + _theme = theme; + _propertyToken = propertyToken; + _visitor = new PlayerLogValueVisitor(theme, formatProvider); + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + if (!logEvent.Properties.TryGetValue(_propertyToken.PropertyName, out var propertyValue)) { + output.Write(_theme.Stylize(_propertyToken.ToString(), PlayerLogThemeStyle.Invalid)); + } else { + _visitor.Format(propertyValue, output); + } + } + } +} diff --git a/src/TShock/Logging/Formatting/TextFormatter.cs b/src/TShock/Logging/Formatting/TextFormatter.cs new file mode 100644 index 000000000..6122fa4be --- /dev/null +++ b/src/TShock/Logging/Formatting/TextFormatter.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class TextFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + private readonly string _text; + + public TextFormatter(PlayerLogTheme theme, string text) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(text != null, "text should not be null"); + + _theme = theme; + _text = text; + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + output.Write(_theme.Stylize(_text, PlayerLogThemeStyle.Text)); + } + } +} diff --git a/src/TShock/Logging/Formatting/TimestampFormatter.cs b/src/TShock/Logging/Formatting/TimestampFormatter.cs new file mode 100644 index 000000000..5e05631a0 --- /dev/null +++ b/src/TShock/Logging/Formatting/TimestampFormatter.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.IO; +using Serilog.Events; +using Serilog.Formatting; +using TShock.Logging.Themes; + +namespace TShock.Logging.Formatting { + internal sealed class TimestampFormatter : ITextFormatter { + private readonly PlayerLogTheme _theme; + private readonly string _format; + private readonly IFormatProvider? _formatProvider; + + public TimestampFormatter(PlayerLogTheme theme, string format, IFormatProvider? formatProvider) { + Debug.Assert(theme != null, "theme should not be null"); + Debug.Assert(format != null, "format should not be null"); + + _theme = theme; + _format = format; + _formatProvider = formatProvider; + } + + public void Format(LogEvent logEvent, TextWriter output) { + Debug.Assert(logEvent != null, "log event should not be null"); + Debug.Assert(output != null, "output should not be null"); + + var timestamp = logEvent.Timestamp.ToString(_format, _formatProvider); + output.Write(_theme.Stylize(timestamp, PlayerLogThemeStyle.Timestamp)); + } + } +} diff --git a/src/TShock/Logging/PlayerLogSink.cs b/src/TShock/Logging/PlayerLogSink.cs new file mode 100644 index 000000000..66a9666eb --- /dev/null +++ b/src/TShock/Logging/PlayerLogSink.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics; +using System.IO; +using Microsoft.Xna.Framework; +using Orion.Players; +using Serilog.Core; +using Serilog.Events; +using Serilog.Formatting; + +namespace TShock.Logging { + internal sealed class PlayerLogSink : ILogEventSink { + private readonly IPlayer _player; + private readonly ITextFormatter _formatter; + + public PlayerLogSink(IPlayer player, ITextFormatter formatter) { + Debug.Assert(player != null, "player should not be null"); + Debug.Assert(formatter != null, "formatter should not be null"); + + _player = player; + _formatter = formatter; + } + + public void Emit(LogEvent logEvent) { + Debug.Assert(logEvent != null, "log event should not be null"); + + var output = new StringWriter(); + _formatter.Format(logEvent, output); + + // When sending the text, we don't want any newlines if possible. So we strip the ending newline. + var text = output.ToString(); + if (text.EndsWith('\n')) { + text = text[0..^1]; + } + + _player.SendMessage(text, Color.White); + } + } +} diff --git a/src/TShock/Logging/PlayerLogValueVisitor.cs b/src/TShock/Logging/PlayerLogValueVisitor.cs new file mode 100644 index 000000000..f1c58d307 --- /dev/null +++ b/src/TShock/Logging/PlayerLogValueVisitor.cs @@ -0,0 +1,126 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Serilog.Data; +using Serilog.Events; +using TShock.Logging.Themes; +using Unit = System.ValueTuple; + +namespace TShock.Logging { + [SuppressMessage( + "Globalization", "CA1303:Do not pass literals as localized parameters", + Justification = "symbols should not be localized")] + [SuppressMessage( + "Design", "CA1062:Validate arguments of public methods", + Justification = "validation has already occurred")] + internal sealed class PlayerLogValueVisitor : LogEventPropertyValueVisitor { + private readonly PlayerLogTheme _theme; + private readonly IFormatProvider? _formatProvider; + + public PlayerLogValueVisitor(PlayerLogTheme theme, IFormatProvider? formatProvider = null) { + Debug.Assert(theme != null, "theme should not be null"); + + _theme = theme ?? throw new ArgumentNullException(nameof(theme)); + _formatProvider = formatProvider; + } + + public Unit Format(LogEventPropertyValue value, TextWriter output) { + Debug.Assert(value != null, "value should not be null"); + Debug.Assert(output != null, "output should not be null"); + + return Visit(output, value); + } + + protected override Unit VisitScalarValue(TextWriter output, ScalarValue scalar) { + var value = scalar.Value; + var text = value is IFormattable f ? f.ToString(null, _formatProvider) : value?.ToString() ?? "null"; + var style = scalar.Value switch { + null => PlayerLogThemeStyle.Null, + string _ => PlayerLogThemeStyle.String, + bool _ => PlayerLogThemeStyle.Boolean, + char _ => PlayerLogThemeStyle.Character, + var n when n.GetType().IsPrimitive || n is decimal => PlayerLogThemeStyle.Number, + _ => PlayerLogThemeStyle.Scalar + }; + output.Write(_theme.Stylize(text, style)); + return default; + } + + protected override Unit VisitSequenceValue(TextWriter output, SequenceValue sequence) { + var includeSeparator = false; + output.Write(_theme.Stylize("[", PlayerLogThemeStyle.Text)); + foreach (var element in sequence.Elements) { + if (includeSeparator) { + output.Write(_theme.Stylize(", ", PlayerLogThemeStyle.Separator)); + } + + Visit(output, element); + includeSeparator = true; + } + + output.Write(_theme.Stylize("]", PlayerLogThemeStyle.Text)); + return default; + } + + protected override Unit VisitStructureValue(TextWriter output, StructureValue structure) { + var typeTag = structure.TypeTag; + if (typeTag != null) { + output.Write(_theme.Stylize(typeTag, PlayerLogThemeStyle.Type)); + } + + var includeSeparator = false; + output.Write(_theme.Stylize("{", PlayerLogThemeStyle.Text)); + foreach (var property in structure.Properties) { + if (includeSeparator) { + output.Write(_theme.Stylize(", ", PlayerLogThemeStyle.Separator)); + } + + output.Write(_theme.Stylize(property.Name, PlayerLogThemeStyle.Identifier)); + output.Write(_theme.Stylize("=", PlayerLogThemeStyle.Separator)); + Visit(output, property.Value); + includeSeparator = true; + } + + output.Write(_theme.Stylize("}", PlayerLogThemeStyle.Text)); + return default; + } + + protected override Unit VisitDictionaryValue(TextWriter output, DictionaryValue dictionary) { + var includeSeparator = false; + output.Write(_theme.Stylize("{", PlayerLogThemeStyle.Text)); + foreach (var kvp in dictionary.Elements) { + if (includeSeparator) { + output.Write(_theme.Stylize(", ", PlayerLogThemeStyle.Separator)); + } + + output.Write(_theme.Stylize("[", PlayerLogThemeStyle.Text)); + Visit(output, kvp.Key); + output.Write(_theme.Stylize("]", PlayerLogThemeStyle.Text)); + output.Write(_theme.Stylize("=", PlayerLogThemeStyle.Separator)); + Visit(output, kvp.Value); + includeSeparator = true; + } + + output.Write(_theme.Stylize("}", PlayerLogThemeStyle.Text)); + return default; + } + } +} diff --git a/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs b/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs new file mode 100644 index 000000000..3fbf4b8de --- /dev/null +++ b/src/TShock/Logging/PlayerLoggerConfigurationExtensions.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using Orion.Players; +using Serilog; +using Serilog.Configuration; +using Serilog.Core; +using Serilog.Events; +using TShock.Logging.Formatting; +using TShock.Logging.Themes; + +namespace TShock.Logging { + /// + /// Extends with a WriteTo.Player() method. + /// + public static class PlayerLoggerConfigurationExtensions { + private const string DefaultOutputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {Message}{NewLine}{Exception}"; + + /// + /// Writes log events to the given with optional settings. + /// + /// The logger sink configuration. + /// The player. + /// The minimum level for events to be logged. + /// The output template. + /// The format provider, or for a default one. + /// The level switch, or for none. + /// The theme, or for a default one. + /// A logger configuration. + /// + /// , , or are + /// . + /// + public static LoggerConfiguration Player( + this LoggerSinkConfiguration configuration, IPlayer player, + LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum, + string outputTemplate = DefaultOutputTemplate, + IFormatProvider? formatProvider = null, + LoggingLevelSwitch? levelSwitch = null, + PlayerLogTheme? theme = null) { + if (configuration is null) { + throw new ArgumentNullException(nameof(configuration)); + } + + if (player is null) { + throw new ArgumentNullException(nameof(player)); + } + + if (outputTemplate is null) { + throw new ArgumentNullException(nameof(outputTemplate)); + } + + var formatter = new PlayerLogFormatter( + theme ?? PlayerLogThemes.VisualStudio, outputTemplate, formatProvider); + var sink = new PlayerLogSink(player, formatter); + return configuration.Sink(sink, restrictedToMinimumLevel, levelSwitch); + } + } +} diff --git a/src/TShock/Logging/Themes/PlayerLogTheme.cs b/src/TShock/Logging/Themes/PlayerLogTheme.cs new file mode 100644 index 000000000..a76889243 --- /dev/null +++ b/src/TShock/Logging/Themes/PlayerLogTheme.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.Xna.Framework; +using TShock.Utils.Extensions; + +namespace TShock.Logging.Themes { + /// + /// Represents a player log theme. + /// + public sealed class PlayerLogTheme { + private readonly IDictionary _styleToColor; + + internal PlayerLogTheme(IDictionary styleToColor) { + Debug.Assert(styleToColor != null, "style to color map should not be null"); + + _styleToColor = styleToColor; + } + + internal string Stylize(string text, PlayerLogThemeStyle style) { + Debug.Assert(text != null, "Text should not be null"); + + return _styleToColor.TryGetValue(style, out var color) ? text.WithColor(color) : text; + } + } +} diff --git a/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs b/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs new file mode 100644 index 000000000..aafc20801 --- /dev/null +++ b/src/TShock/Logging/Themes/PlayerLogThemeStyle.cs @@ -0,0 +1,123 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Diagnostics.CodeAnalysis; + +namespace TShock.Logging.Themes { + /// + /// Specifies an entity styled by a player log theme. + /// + [SuppressMessage( + "Naming", "CA1720:Identifier contains type name", + Justification = "String is only a keyword in Visual Basic")] + public enum PlayerLogThemeStyle { + /// + /// Represents text. + /// + Text, + + /// + /// Represents a separator in, e.g., a sequence or structure. + /// + Separator, + + /// + /// Represents . + /// + Null, + + /// + /// Represents a . + /// + Boolean, + + /// + /// Represents a . + /// + String, + + /// + /// Represents a . + /// + Character, + + /// + /// Represents a number. + /// + Number, + + /// + /// Represents all other scalar values. + /// + Scalar, + + /// + /// Represents a property identifier. + /// + Identifier, + + /// + /// Represents a structure type. + /// + Type, + + /// + /// Represents a timestamp. + /// + Timestamp, + + /// + /// Represents an exception. + /// + Exception, + + /// + /// Represents the verbose level indicator. + /// + VerboseLevel, + + /// + /// Represents the debug level indicator. + /// + DebugLevel, + + /// + /// Represents the information level indicator. + /// + InformationLevel, + + /// + /// Represents the warning level indicator. + /// + WarningLevel, + + /// + /// Represents the error level indicator. + /// + ErrorLevel, + + /// + /// Represents the fatal level indicator. + /// + FatalLevel, + + /// + /// Represents something invalid: e.g., an unknown level indicator or a missing property. + /// + Invalid + } +} diff --git a/src/TShock/Logging/Themes/PlayerLogThemes.cs b/src/TShock/Logging/Themes/PlayerLogThemes.cs new file mode 100644 index 000000000..28718a97d --- /dev/null +++ b/src/TShock/Logging/Themes/PlayerLogThemes.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace TShock.Logging.Themes { + /// + /// Provides instances. + /// + public static class PlayerLogThemes { + /// + /// Gets a theme that uses Visual Studio's colors wherever possible. + /// + /// A theme that uses Visual Studio's colors wherever possible. + public static PlayerLogTheme VisualStudio { get; } = + new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Text] = new Color(0xdc, 0xdc, 0xdc), + [PlayerLogThemeStyle.Separator] = new Color(0x9b, 0x9b, 0x9b), + [PlayerLogThemeStyle.Null] = new Color(0x56, 0x9c, 0xd6), + [PlayerLogThemeStyle.Boolean] = new Color(0x56, 0x9c, 0xd6), + [PlayerLogThemeStyle.String] = new Color(0xd6, 0x9d, 0x85), + [PlayerLogThemeStyle.Character] = new Color(0xd6, 0x9d, 0x85), + [PlayerLogThemeStyle.Number] = new Color(0xb5, 0xce, 0xa8), + [PlayerLogThemeStyle.Scalar] = new Color(0x86, 0xc6, 0x91), + [PlayerLogThemeStyle.Identifier] = new Color(0x9c, 0xdc, 0xfe), + [PlayerLogThemeStyle.Type] = new Color(0x4e, 0xc9, 0xb0), + + // The below don't really have any analogs, so I made some colors up. + [PlayerLogThemeStyle.Timestamp] = new Color(0x86, 0xc6, 0x91), + [PlayerLogThemeStyle.Exception] = new Color(0xcc, 0x44, 0x44), + [PlayerLogThemeStyle.VerboseLevel] = new Color(0xcc, 0xcc, 0xcc), + [PlayerLogThemeStyle.DebugLevel] = new Color(0xff, 0xff, 0x44), + [PlayerLogThemeStyle.WarningLevel] = new Color(0xff, 0x88, 0x44), + [PlayerLogThemeStyle.ErrorLevel] = new Color(0xcc, 0x44, 0x44), + [PlayerLogThemeStyle.FatalLevel] = new Color(0xff, 0x00, 0x00), + [PlayerLogThemeStyle.Invalid] = new Color(0xcc, 0x44, 0x44), + }); + } +} diff --git a/src/TShock/Modules/CommandModule.cs b/src/TShock/Modules/CommandModule.cs new file mode 100644 index 000000000..f04a67be4 --- /dev/null +++ b/src/TShock/Modules/CommandModule.cs @@ -0,0 +1,319 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Linq; +using Microsoft.Xna.Framework; +using Orion; +using Orion.Events; +using Orion.Events.Players; +using Orion.Events.Server; +using Orion.Players; +using Serilog; +using TShock.Commands; +using TShock.Commands.Exceptions; +using TShock.Commands.Parsers; +using TShock.Commands.Parsers.Attributes; +using TShock.Events.Commands; +using TShock.Properties; +using TShock.Utils.Extensions; + +namespace TShock.Modules { + /// + /// Represents TShock's command module. Provides command functionality and core commands. + /// + internal sealed class CommandModule : TShockModule { + private readonly OrionKernel _kernel; + private readonly ILogger _log; + private readonly IPlayerService _playerService; + private readonly ICommandService _commandService; + + private readonly IDictionary _terrariaCommandToCommand = new Dictionary { + ["Say"] = string.Empty, + ["Emote"] = Resources.TerrariaCommand_Me, + ["Party"] = Resources.TerrariaCommand_P, + ["Playing"] = Resources.TerrariaCommand_Playing, + ["Roll"] = Resources.TerrariaCommand_Roll + }; + + private readonly IDictionary> _nameToQualifiedName + = new Dictionary>(); + + private readonly Random _rand = new Random(); + + public CommandModule( + OrionKernel kernel, ILogger log, IPlayerService playerService, ICommandService commandService) { + Debug.Assert(kernel != null, "kernel should not be null"); + Debug.Assert(log != null, "log should not be null"); + Debug.Assert(playerService != null, "player service should not be null"); + Debug.Assert(commandService != null, "command service should not be null"); + + _kernel = kernel; + _log = log; + _playerService = playerService; + _commandService = commandService; + + _kernel.RegisterHandlers(this, _log); + } + + public override void Dispose() => _kernel.UnregisterHandlers(this, _log); + + public override void Initialize() { + _commandService.RegisterParser(new Int32Parser()); + _commandService.RegisterParser(new DoubleParser()); + _commandService.RegisterParser(new StringParser()); + _commandService.RegisterCommands(this); + } + + // Executes a command. input should not have the leading /. + public void ExecuteCommand(ICommandSender commandSender, string input) { + var space = input.IndexOf(' ', StringComparison.Ordinal); + if (space < 0) { + space = input.Length; + } + + var commandName = input.Substring(0, space); + ICommand command; + try { + var qualifiedName = GetQualifiedName(commandName); + command = _commandService.Commands[qualifiedName]; + } catch (CommandNotFoundException ex) { + commandSender.SendErrorMessage(ex.Message); + return; + } + + if (command.ShouldBeLogged) { + Log.Information(Resources.Log_ExecutingCommand, commandSender.Name, input); + } + + try { + command.Invoke(commandSender, input.Substring(space)); + } catch (CommandParseException ex) { + commandSender.SendErrorMessage(ex.Message); + commandSender.SendInfoMessage( + string.Format(CultureInfo.InvariantCulture, command.UsageText, commandName)); + } catch (CommandExecuteException ex) { + commandSender.SendErrorMessage(ex.Message); + } + } + + // Gets the qualified command name for a possibly-qualified command name. + public string GetQualifiedName(string maybeQualifiedName) { + if (string.IsNullOrWhiteSpace(maybeQualifiedName)) { + throw new CommandNotFoundException(Resources.CommandNotFound_MissingCommand); + } + + var isQualifiedName = maybeQualifiedName.IndexOf(':', StringComparison.Ordinal) >= 0; + if (isQualifiedName) { + if (!_commandService.Commands.ContainsKey(maybeQualifiedName)) { + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, + maybeQualifiedName)); + } + + return maybeQualifiedName; + } + + var qualifiedNames = _nameToQualifiedName.GetValueOrDefault(maybeQualifiedName, () => new HashSet()); + if (qualifiedNames.Count == 0) { + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_UnrecognizedCommand, + maybeQualifiedName)); + } + + if (qualifiedNames.Count > 1) { + throw new CommandNotFoundException( + string.Format(CultureInfo.InvariantCulture, Resources.CommandNotFound_AmbiguousName, + maybeQualifiedName, string.Join(", ", qualifiedNames.Select(n => $"\"/{n}\"")))); + } + + return qualifiedNames.Single(); + } + + [EventHandler(Name = "tshock")] + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + private void ServerCommandHandler(ServerCommandEvent e) { + if (e.IsCanceled()) { + return; + } + + e.Cancel("tshock: command executing"); + + var input = e.Input; + if (input.StartsWith('/')) { + input = input.Substring(1); + } + + ExecuteCommand(ConsoleCommandSender.Instance, input); + } + + [EventHandler(Name = "tshock")] + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + private void PlayerChatHandler(PlayerChatEvent e) { + if (e.IsCanceled()) { + return; + } + + var chatCommand = e.Command; + if (!_terrariaCommandToCommand.TryGetValue(chatCommand, out var canonicalCommand)) { + e.Cancel("tshock: Terraria command is invalid"); + return; + } + + var chat = canonicalCommand + e.Text; + if (chat.StartsWith('/')) { + e.Cancel("tshock: command executing"); + + ICommandSender commandSender = e.Player.GetAnnotationOrDefault( + "tshock:CommandSender", + () => new PlayerCommandSender(e.Player), true); + var input = chat.Substring(1); + ExecuteCommand(commandSender, input); + } + } + + [EventHandler(EventPriority.Monitor, Name = "tshock")] + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + private void CommandRegisterHandler(CommandRegisterEvent e) { + var qualifiedName = e.Command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); + _nameToQualifiedName + .GetValueOrDefault(name, () => new HashSet(), true) + .Add(qualifiedName); + } + + [EventHandler(EventPriority.Monitor, Name = "tshock")] + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + private void CommandUnregisterHandler(CommandUnregisterEvent e) { + var qualifiedName = e.Command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); + _nameToQualifiedName + .GetValueOrDefault(name, () => new HashSet(), true) + .Remove(qualifiedName); + } + + // ============================================================================================================= + // Core command implementations below: + // + + [CommandHandler(nameof(Resources.Command_Help), + HelpText = nameof(Resources.Command_Help_HelpText), + UsageText = nameof(Resources.Command_Help_UsageText), + ResourceType = typeof(Resources))] + public void Help(ICommandSender sender, string? command_name = null) { + if (command_name is null) { + string FormatCommandName(ICommand command) { + var qualifiedName = command.QualifiedName; + var name = qualifiedName.Substring(qualifiedName.IndexOf(':', StringComparison.Ordinal) + 1); + return _nameToQualifiedName[name].Count > 1 ? qualifiedName : name; + } + + sender.SendInfoMessage(Resources.Command_Help_Header); + sender.SendInfoMessage( + string.Join(", ", _commandService.Commands.Values.Select(c => $"/{FormatCommandName(c)}"))); + return; + } + + if (command_name.StartsWith('/')) { + command_name = command_name.Substring(1); + } + + string qualifiedName; + try { + qualifiedName = GetQualifiedName(command_name); + } catch (CommandNotFoundException ex) { + sender.SendErrorMessage(ex.Message); + return; + } + + var command = _commandService.Commands[qualifiedName]; + var usageText = string.Format(CultureInfo.InvariantCulture, command.UsageText, command_name); + sender.SendInfoMessage($"/{command_name}:\n{command.HelpText}\n{usageText}"); + } + + [CommandHandler(nameof(Resources.Command_Playing), + HelpText = nameof(Resources.Command_Playing_HelpText), + UsageText = nameof(Resources.Command_Playing_UsageText), + ResourceType = typeof(Resources))] + public void Playing(ICommandSender sender, [Flag("i")] bool showIds) { + var onlinePlayers = _playerService.Players.Where(p => p.IsActive).ToList(); + if (onlinePlayers.Count == 0) { + sender.SendInfoMessage(Resources.Command_Playing_NoPlayers); + return; + } + + sender.SendInfoMessage(Resources.Command_Playing_Header); + if (showIds) { + sender.SendInfoMessage(string.Join(", ", onlinePlayers.Select(p => $"[{p.Index}] {p.Name}"))); + } else { + sender.SendInfoMessage(string.Join(", ", onlinePlayers.Select(p => p.Name))); + } + } + + [CommandHandler(nameof(Resources.Command_Me), + HelpText = nameof(Resources.Command_Me_HelpText), + UsageText = nameof(Resources.Command_Me_UsageText), + ResourceType = typeof(Resources))] + public void Me(ICommandSender sender, [RestOfInput] string text) { + _playerService.BroadcastMessage( + string.Format(CultureInfo.InvariantCulture, Resources.Command_Me_Message, sender.Name, text), + new Color(0xc8, 0x64, 0x00)); + Log.Information(Resources.Log_Command_Me_Message, sender.Name, text); + } + + [CommandHandler(nameof(Resources.Command_Roll), + HelpText = nameof(Resources.Command_Roll_HelpText), + UsageText = nameof(Resources.Command_Roll_UsageText), + ResourceType = typeof(Resources))] + public void Roll(ICommandSender sender) { + var num = _rand.Next(1, 101); + _playerService.BroadcastMessage( + string.Format(CultureInfo.InvariantCulture, Resources.Command_Roll_Message, sender.Name, num), + new Color(0xff, 0xf0, 0x14)); + Log.Information(Resources.Log_Command_Roll_Message, sender.Name, num); + } + + [CommandHandler(nameof(Resources.Command_P), + HelpText = nameof(Resources.Command_P_HelpText), + UsageText = nameof(Resources.Command_P_UsageText), + ResourceType = typeof(Resources))] + public void P(ICommandSender sender, [RestOfInput] string text) { + var player = sender.Player; + if (player is null) { + sender.SendErrorMessage(Resources.Command_P_NotAPlayer); + return; + } + + var team = player.Team; + if (team == PlayerTeam.None) { + sender.SendErrorMessage(Resources.Command_P_NotInTeam); + return; + } + + var teamColor = team.Color(); + var teamPlayers = _playerService.Players.Where(p => p.IsActive && p.Team == team); + foreach (var teamPlayer in teamPlayers) { + teamPlayer.SendMessageFrom(player, text, teamColor); + } + Log.Information(Resources.Log_Command_P_Message, player.Name, team, text); + } + } +} diff --git a/src/TShock/Modules/TShockModule.cs b/src/TShock/Modules/TShockModule.cs new file mode 100644 index 000000000..4d3582a51 --- /dev/null +++ b/src/TShock/Modules/TShockModule.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace TShock.Modules { + /// + /// Represents a module of TShock's functionality. + /// + [SuppressMessage( + "Design", "CA1063:Implement IDisposable Correctly", + Justification = "IDisposable pattern makes no sense")] + public abstract class TShockModule : IDisposable { + /// + /// Disposes the service, releasing any resources associated with it. + /// + [SuppressMessage( + "Usage", "CA1816:Dispose methods should call SuppressFinalize", + Justification = "IDisposable pattern makes no sense")] + public virtual void Dispose() { } + + /// + /// Initializes the module. Typically, commands should be registered here. + /// + public abstract void Initialize(); + } +} diff --git a/src/TShock/Properties/Resources.Designer.cs b/src/TShock/Properties/Resources.Designer.cs index fb0ec952f..489da05a4 100644 --- a/src/TShock/Properties/Resources.Designer.cs +++ b/src/TShock/Properties/Resources.Designer.cs @@ -59,5 +59,437 @@ internal Resources() { resourceCulture = value; } } + + /// + /// Looks up a localized string similar to tshock:help. + /// + internal static string Command_Help { + get { + return ResourceManager.GetString("Command_Help", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Commands:. + /// + internal static string Command_Help_Header { + get { + return ResourceManager.GetString("Command_Help_Header", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shows available commands and provides information about specific commands.. + /// + internal static string Command_Help_HelpText { + get { + return ResourceManager.GetString("Command_Help_HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage: /{0} [command-name]. + /// + internal static string Command_Help_UsageText { + get { + return ResourceManager.GetString("Command_Help_UsageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to tshock:me. + /// + internal static string Command_Me { + get { + return ResourceManager.GetString("Command_Me", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends a message in the third person.. + /// + internal static string Command_Me_HelpText { + get { + return ResourceManager.GetString("Command_Me_HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to *{0} {1}. + /// + internal static string Command_Me_Message { + get { + return ResourceManager.GetString("Command_Me_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage: /{0} <text>. + /// + internal static string Command_Me_UsageText { + get { + return ResourceManager.GetString("Command_Me_UsageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing command help text.. + /// + internal static string Command_MissingHelpText { + get { + return ResourceManager.GetString("Command_MissingHelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing command usage text.. + /// + internal static string Command_MissingUsageText { + get { + return ResourceManager.GetString("Command_MissingUsageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to tshock:p. + /// + internal static string Command_P { + get { + return ResourceManager.GetString("Command_P", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Sends a message to your party members.. + /// + internal static string Command_P_HelpText { + get { + return ResourceManager.GetString("Command_P_HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You must be a player to party chat.. + /// + internal static string Command_P_NotAPlayer { + get { + return ResourceManager.GetString("Command_P_NotAPlayer", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to You are not in a team!. + /// + internal static string Command_P_NotInTeam { + get { + return ResourceManager.GetString("Command_P_NotInTeam", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage: /{0} <text>. + /// + internal static string Command_P_UsageText { + get { + return ResourceManager.GetString("Command_P_UsageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to tshock:playing. + /// + internal static string Command_Playing { + get { + return ResourceManager.GetString("Command_Playing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Players online:. + /// + internal static string Command_Playing_Header { + get { + return ResourceManager.GetString("Command_Playing_Header", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Shows the current players.. + /// + internal static string Command_Playing_HelpText { + get { + return ResourceManager.GetString("Command_Playing_HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No players online.. + /// + internal static string Command_Playing_NoPlayers { + get { + return ResourceManager.GetString("Command_Playing_NoPlayers", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage: /{0} [-i]. + /// + internal static string Command_Playing_UsageText { + get { + return ResourceManager.GetString("Command_Playing_UsageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to tshock:roll. + /// + internal static string Command_Roll { + get { + return ResourceManager.GetString("Command_Roll", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Rolls a random number from 1 to 100.. + /// + internal static string Command_Roll_HelpText { + get { + return ResourceManager.GetString("Command_Roll_HelpText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to *{0} rolls a {1}. + /// + internal static string Command_Roll_Message { + get { + return ResourceManager.GetString("Command_Roll_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Usage: /{0}. + /// + internal static string Command_Roll_UsageText { + get { + return ResourceManager.GetString("Command_Roll_UsageText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Exception occurred while executing command.. + /// + internal static string CommandExecute_Exception { + get { + return ResourceManager.GetString("CommandExecute_Exception", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to "/{0}" is ambiguous and can refer to {1}.. + /// + internal static string CommandNotFound_AmbiguousName { + get { + return ResourceManager.GetString("CommandNotFound_AmbiguousName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing command name. Type /help for a list of commands.. + /// + internal static string CommandNotFound_MissingCommand { + get { + return ResourceManager.GetString("CommandNotFound_MissingCommand", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Command "/{0}" is not recognized. Type /help for a list of commands.. + /// + internal static string CommandNotFound_UnrecognizedCommand { + get { + return ResourceManager.GetString("CommandNotFound_UnrecognizedCommand", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid hyphenated argument.. + /// + internal static string CommandParse_InvalidHyphenatedArg { + get { + return ResourceManager.GetString("CommandParse_InvalidHyphenatedArg", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Missing argument "{0}".. + /// + internal static string CommandParse_MissingArg { + get { + return ResourceManager.GetString("CommandParse_MissingArg", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Too many arguments were provided.. + /// + internal static string CommandParse_TooManyArgs { + get { + return ResourceManager.GetString("CommandParse_TooManyArgs", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Argument type "{0}" not recognized.. + /// + internal static string CommandParse_UnrecognizedArgType { + get { + return ResourceManager.GetString("CommandParse_UnrecognizedArgType", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Long flag "--{0}" not recognized.. + /// + internal static string CommandParse_UnrecognizedLongFlag { + get { + return ResourceManager.GetString("CommandParse_UnrecognizedLongFlag", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Optional argument "{0}" not recognized.. + /// + internal static string CommandParse_UnrecognizedOptional { + get { + return ResourceManager.GetString("CommandParse_UnrecognizedOptional", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Short flag "-{0}" not recognized.. + /// + internal static string CommandParse_UnrecognizedShortFlag { + get { + return ResourceManager.GetString("CommandParse_UnrecognizedShortFlag", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to "{0}" is an invalid number.. + /// + internal static string DoubleParser_InvalidDouble { + get { + return ResourceManager.GetString("DoubleParser_InvalidDouble", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to "{0}" is a number that is out of range of an integer.. + /// + internal static string Int32Parser_IntegerOutOfRange { + get { + return ResourceManager.GetString("Int32Parser_IntegerOutOfRange", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to "{0}" is an invalid integer.. + /// + internal static string Int32Parser_InvalidInteger { + get { + return ResourceManager.GetString("Int32Parser_InvalidInteger", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to *{Name} {Text}. + /// + internal static string Log_Command_Me_Message { + get { + return ResourceManager.GetString("Log_Command_Me_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to <{Player} (to {Team} team)> {Text}. + /// + internal static string Log_Command_P_Message { + get { + return ResourceManager.GetString("Log_Command_P_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to *{Name} rolls a {Num}. + /// + internal static string Log_Command_Roll_Message { + get { + return ResourceManager.GetString("Log_Command_Roll_Message", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {Sender} is executing command /{Command}. + /// + internal static string Log_ExecutingCommand { + get { + return ResourceManager.GetString("Log_ExecutingCommand", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Invalid backslash.. + /// + internal static string StringParser_InvalidBackslash { + get { + return ResourceManager.GetString("StringParser_InvalidBackslash", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Escape character "\{0}" not recognized.. + /// + internal static string StringParser_UnrecognizedEscape { + get { + return ResourceManager.GetString("StringParser_UnrecognizedEscape", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to /me . + /// + internal static string TerrariaCommand_Me { + get { + return ResourceManager.GetString("TerrariaCommand_Me", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to /p . + /// + internal static string TerrariaCommand_P { + get { + return ResourceManager.GetString("TerrariaCommand_P", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to /playing . + /// + internal static string TerrariaCommand_Playing { + get { + return ResourceManager.GetString("TerrariaCommand_Playing", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to /roll . + /// + internal static string TerrariaCommand_Roll { + get { + return ResourceManager.GetString("TerrariaCommand_Roll", resourceCulture); + } + } } } diff --git a/src/TShock/Properties/Resources.resx b/src/TShock/Properties/Resources.resx index 1af7de150..299a9715f 100644 --- a/src/TShock/Properties/Resources.resx +++ b/src/TShock/Properties/Resources.resx @@ -117,4 +117,148 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Exception occurred while executing command. + + + "/{0}" is ambiguous and can refer to {1}. + + + Missing command name. Type /help for a list of commands. + + + Command "/{0}" is not recognized. Type /help for a list of commands. + + + Invalid hyphenated argument. + + + Missing argument "{0}". + + + Too many arguments were provided. + + + Argument type "{0}" not recognized. + + + Long flag "--{0}" not recognized. + + + Optional argument "{0}" not recognized. + + + Short flag "-{0}" not recognized. + + + tshock:help + + + Commands: + + + Shows available commands and provides information about specific commands. + + + Usage: /{0} [command-name] + + + tshock:me + + + Sends a message in the third person. + + + *{0} {1} + + + Usage: /{0} <text> + + + Missing command help text. + + + Missing command usage text. + + + tshock:p + + + tshock:playing + + + Players online: + + + Shows the current players. + + + No players online. + + + Usage: /{0} [-i] + + + Sends a message to your party members. + + + You must be a player to party chat. + + + You are not in a team! + + + Usage: /{0} <text> + + + tshock:roll + + + Rolls a random number from 1 to 100. + + + *{0} rolls a {1} + + + Usage: /{0} + + + "{0}" is an invalid number. + + + "{0}" is a number that is out of range of an integer. + + + "{0}" is an invalid integer. + + + *{Name} {Text} + + + <{Player} (to {Team} team)> {Text} + + + *{Name} rolls a {Num} + + + {Sender} is executing command /{Command} + + + Invalid backslash. + + + Escape character "\{0}" not recognized. + + + /me + + + /p + + + /playing + + + /roll + \ No newline at end of file diff --git a/src/TShock/TShock.csproj b/src/TShock/TShock.csproj index 2441febd0..95386279d 100644 --- a/src/TShock/TShock.csproj +++ b/src/TShock/TShock.csproj @@ -14,10 +14,13 @@ git en-US COPYING + OnBuildSuccess bin\$(Configuration)\TShock.xml + true + @@ -32,6 +35,12 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + @@ -52,7 +61,7 @@ - + diff --git a/src/TShock/TShockPlugin.cs b/src/TShock/TShockPlugin.cs index a3ee02ea8..7c7cf9db0 100644 --- a/src/TShock/TShockPlugin.cs +++ b/src/TShock/TShockPlugin.cs @@ -16,51 +16,90 @@ // along with TShock. If not, see . using System; +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using Orion; using Orion.Events; -using Orion.Events.Packets; +using Orion.Events.Server; using Orion.Players; +using Serilog; +using TShock.Commands; +using TShock.Modules; namespace TShock { /// /// Represents the TShock plugin. /// + [Service("tshock")] public sealed class TShockPlugin : OrionPlugin { private readonly Lazy _playerService; + private readonly Lazy _commandService; - /// - [ExcludeFromCodeCoverage] - public override string Author => "Pryaxis"; - - /// - [ExcludeFromCodeCoverage] - public override string Name => "TShock"; + private readonly ISet _modules = new HashSet(); /// - /// Initializes a new instance of the class with the specified Orion kernel and - /// services. + /// Initializes a new instance of the class with the specified + /// instance, log, and services. /// - /// The Orion kernel. + /// The instance. + /// The log. /// The player service. - /// Any of the services are null. - public TShockPlugin(OrionKernel kernel, Lazy playerService) : base(kernel) { + /// The command service. + /// + /// , , or any of the services are . + /// + public TShockPlugin( + OrionKernel kernel, ILogger log, Lazy playerService, + Lazy commandService) : base(kernel, log) { + Kernel.Bind().To(); + _playerService = playerService ?? throw new ArgumentNullException(nameof(playerService)); + _commandService = commandService ?? throw new ArgumentNullException(nameof(commandService)); } - /// - protected override void Initialize() { - _playerService.Value.PacketReceive += PacketReceiveHandler; + /// + /// Registers the given . + /// + /// The module. + /// is . + public void RegisterModule(TShockModule module) { + if (module is null) { + throw new ArgumentNullException(nameof(module)); + } + + _modules.Add(module); } - /// - protected override void Dispose(bool disposeManaged) { - if (!disposeManaged) return; + /// + public override void Initialize() { + Kernel.RegisterHandlers(this, Log); + RegisterModule(new CommandModule(Kernel, Log, _playerService.Value, _commandService.Value)); + } - _playerService.Value.PacketReceive -= PacketReceiveHandler; + /// + public override void Dispose() { + Kernel.UnregisterHandlers(this, Log); + foreach (var module in _modules) { + module.Dispose(); + } } - [EventHandler(EventPriority.Lowest)] - private void PacketReceiveHandler(object sender, PacketReceiveEventArgs args) { } + [EventHandler] + [SuppressMessage("Usage", "CA1801:Review unused parameters:", Justification = "Parameter is required")] + [SuppressMessage("Code Quality", "IDE0051:Remove unused private members", Justification = "Implicit usage")] + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Parameter is required")] + private void OnServerInitialize(ServerInitializeEvent e) { + // The reason why we want to wait for ServerInitialize to initialize the modules is because we need three + // three stages of initialization: + // + // 1) Plugin constructors, which use lazy services. + // 2) Plugin Initialize, which can set up service event handlers, + // 3) ServerInitialize, which can use any service. + // + // This way, we can have service rebinding and service event handlers that always run. + foreach (var module in _modules) { + module.Initialize(); + } + } } } diff --git a/src/TShock/Utils/Extensions/ColorExtensions.cs b/src/TShock/Utils/Extensions/ColorExtensions.cs new file mode 100644 index 000000000..1bc1b0158 --- /dev/null +++ b/src/TShock/Utils/Extensions/ColorExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using Microsoft.Xna.Framework; + +namespace TShock.Utils.Extensions { + /// + /// Provides extensions for the structure. + /// + public static class ColorExtensions { + /// + /// Converts the to a hex string representation. + /// + /// The color. + /// The hex string representation. + public static string ToHexString(this Color color) => $"{color.R:x2}{color.G:x2}{color.B:x2}"; + } +} diff --git a/src/TShock/Utils/Extensions/DictionaryExtensions.cs b/src/TShock/Utils/Extensions/DictionaryExtensions.cs new file mode 100644 index 000000000..c9305cdb0 --- /dev/null +++ b/src/TShock/Utils/Extensions/DictionaryExtensions.cs @@ -0,0 +1,63 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; + +namespace TShock.Utils.Extensions { + /// + /// Provides extensions for the interface. + /// + public static class DictionaryExtensions { + /// + /// Gets the value corresponding to the given , using the + /// if does not exist. + /// + /// The type of key. + /// The type of value. + /// The dictionary. + /// The key. + /// + /// The default value provider. If , then the provider will return a default instance of + /// . + /// + /// + /// to create the annotation if it does not exist; otherwise, . + /// + /// + /// The value, or a default instance provided by if + /// does not exist in the dictionary. + /// + /// + /// or are . + /// + public static TValue GetValueOrDefault( + this IDictionary dictionary, TKey key, Func? defaultValueProvider = null, + bool createIfNotExists = false) { + if (dictionary is null) { + throw new ArgumentNullException(nameof(dictionary)); + } + + if (dictionary.TryGetValue(key, out var value)) { + return value; + } + + var provider = defaultValueProvider ?? (() => default!); + return createIfNotExists ? (dictionary[key] = provider())! : provider(); + } + } +} diff --git a/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs b/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs new file mode 100644 index 000000000..e99e311ba --- /dev/null +++ b/src/TShock/Utils/Extensions/ReadOnlySpanExtensions.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; + +namespace TShock.Utils.Extensions { + /// + /// Provides extensions for the structure. + /// + public static class ReadOnlySpanExtensions { + /// + /// Searches for and returns the first index of its occurrence, or the length of the + /// span if it is not found. + /// + /// The type of span. + /// The span. + /// The value. + /// The index of the first occurrence, or the length of the span if it is not found. + public static int IndexOfOrEnd(this ReadOnlySpan span, T value) where T : IEquatable { + var index = span.IndexOf(value); + return index >= 0 ? index : span.Length; + } + } +} diff --git a/src/TShock/Utils/Extensions/StringExtensions.cs b/src/TShock/Utils/Extensions/StringExtensions.cs new file mode 100644 index 000000000..d463db3e5 --- /dev/null +++ b/src/TShock/Utils/Extensions/StringExtensions.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using Microsoft.Xna.Framework; + +namespace TShock.Utils.Extensions { + /// + /// Provides extensions for the class. + /// + public static class StringExtensions { + /// + /// Returns a string with the given . + /// + /// The string. + /// The color. + /// A string with the given . + /// is . + public static string WithColor(this string str, Color color) { + if (str is null) { + throw new ArgumentNullException(nameof(str)); + } + + return $"[c/{color.ToHexString()}:{str}]"; + } + } +} diff --git a/src/TShock/Utils/ResourceHelper.cs b/src/TShock/Utils/ResourceHelper.cs new file mode 100644 index 000000000..de49b0d4e --- /dev/null +++ b/src/TShock/Utils/ResourceHelper.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace TShock.Utils { + /// + /// Provides helper methods for loading resources. + /// + public static class ResourceHelper { + /// + /// Loads a resource from the with the given . + /// + /// The type of resource. + /// The resource type. + /// The name. + /// The resource. + /// + /// is not a property of . + /// + /// + /// or are . + /// + [SuppressMessage( + "Globalization", "CA1303:Do not pass literals as localized parameters", + Justification = "strings are not user-facing")] + public static T LoadResource(Type resourceType, string name) { + if (resourceType is null) { + throw new ArgumentNullException(nameof(resourceType)); + } + + if (name is null) { + throw new ArgumentNullException(nameof(name)); + } + + var property = resourceType.GetProperty(name, + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + if (property is null) { + throw new ArgumentException("Property does not exist.", nameof(name)); + } + + return (T)property.GetValue(null); + } + } +} diff --git a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs index 11c080428..87e08a2f4 100644 --- a/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs +++ b/tests/TShock.Tests/Commands/CommandHandlerAttributeTests.cs @@ -22,31 +22,76 @@ namespace TShock.Commands { public class CommandHandlerAttributeTests { [Fact] - public void Ctor_NullCommandName_ThrowsArgumentNullException() { + public void Ctor_NullQualifiedName_ThrowsArgumentNullException() { Func func = () => new CommandHandlerAttribute(null); func.Should().Throw(); } [Fact] - public void Ctor_NullCommandSubNames_ThrowsArgumentNullException() { - Func func = () => new CommandHandlerAttribute("", null); + public void HelpText_GetWithResourceType() { + var attribute = new CommandHandlerAttribute("tshock_test:test") { + HelpText = nameof(TestClass.HelpText), + ResourceType = typeof(TestClass) + }; - func.Should().Throw(); + attribute.HelpText.Should().Be(TestClass.HelpText); + } + + [Fact] + public void QualifiedName_Get() { + var attribute = new CommandHandlerAttribute("tshock_test:test"); + + attribute.QualifiedName.Should().Be("tshock_test:test"); } [Fact] - public void CommandName_Get_IsCorrect() { - var attribute = new CommandHandlerAttribute("test"); + public void QualifiedName_GetWithResourceType() { + var attribute = new CommandHandlerAttribute(nameof(TestClass.QualifiedName)) { + ResourceType = typeof(TestClass) + }; - attribute.CommandName.Should().Be("test"); + attribute.QualifiedName.Should().Be(TestClass.QualifiedName); } [Fact] - public void CommandSubNames_Get_IsCorrect() { - var attribute = new CommandHandlerAttribute("", "test1", "test2"); + public void HelpText_SetNullValue_ThrowsArgumentNullException() { + var attribute = new CommandHandlerAttribute("tshock_test:test"); + Action action = () => attribute.HelpText = null; + + action.Should().Throw(); + } + + [Fact] + public void UsageText_GetWithResourceType() { + var attribute = new CommandHandlerAttribute("tshock_test:test") { + UsageText = nameof(TestClass.UsageText), + ResourceType = typeof(TestClass) + }; + + attribute.UsageText.Should().Be(TestClass.UsageText); + } + + [Fact] + public void UsageText_SetNullValue_ThrowsArgumentNullException() { + var attribute = new CommandHandlerAttribute("tshock_test:test"); + Action action = () => attribute.UsageText = null; + + action.Should().Throw(); + } + + [Fact] + public void ResourceType_SetNullValue_ThrowsArgumentNullException() { + var attribute = new CommandHandlerAttribute("tshock_test:test"); + Action action = () => attribute.ResourceType = null; + + action.Should().Throw(); + } - attribute.CommandSubNames.Should().BeEquivalentTo("test1", "test2"); + private class TestClass { + public static string QualifiedName => "tshock:qualified_name_test"; + public static string HelpText => "HelpText test"; + public static string UsageText => "UsageText test"; } } } diff --git a/tests/TShock.Tests/Commands/CommandSenderTests.cs b/tests/TShock.Tests/Commands/CommandSenderTests.cs new file mode 100644 index 000000000..e596d5268 --- /dev/null +++ b/tests/TShock.Tests/Commands/CommandSenderTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Moq; +using Xunit; + +namespace TShock.Commands { + public class CommandSenderTests { + [Fact] + public void SendErrorMessage() { + var mockSender = new Mock(); + + mockSender.Object.SendErrorMessage("test"); + + mockSender.Verify(s => s.SendMessage("test", It.IsAny())); + mockSender.VerifyNoOtherCalls(); + } + + [Fact] + public void SendErrorMessage_NullSender_ThrowsArgumentNullException() { + Action action = () => CommandSenderExtensions.SendErrorMessage(null, ""); + + action.Should().Throw(); + } + + [Fact] + public void SendErrorMessage_NullMessage_ThrowsArgumentNullException() { + var sender = new Mock().Object; + Action action = () => sender.SendErrorMessage(null); + + action.Should().Throw(); + } + + [Fact] + public void SendInfoMessage() { + var mockSender = new Mock(); + + mockSender.Object.SendInfoMessage("test"); + + mockSender.Verify(s => s.SendMessage("test", It.IsAny())); + mockSender.VerifyNoOtherCalls(); + } + + [Fact] + public void SendInfoMessage_NullSender_ThrowsArgumentNullException() { + Action action = () => CommandSenderExtensions.SendInfoMessage(null, ""); + + action.Should().Throw(); + } + + [Fact] + public void SendInfoMessage_NullMessage_ThrowsArgumentNullException() { + var sender = new Mock().Object; + Action action = () => sender.SendInfoMessage(null); + + action.Should().Throw(); + } + } +} diff --git a/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs new file mode 100644 index 000000000..d980977c5 --- /dev/null +++ b/tests/TShock.Tests/Commands/ConsoleCommandSenderTests.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using FluentAssertions; +using Xunit; + +namespace TShock.Commands { + public class ConsoleCommandSenderTests { + [Fact] + public void Name_Get() { + ICommandSender sender = ConsoleCommandSender.Instance; + + sender.Name.Should().Be("Console"); + } + + [Fact] + public void Player_Get() { + ICommandSender sender = ConsoleCommandSender.Instance; + + sender.Player.Should().BeNull(); + } + } +} diff --git a/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs b/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs new file mode 100644 index 000000000..16b0eaf45 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/ArgumentParserTests.cs @@ -0,0 +1,39 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Xunit; + +namespace TShock.Commands.Parsers { + public class ArgumentParserTests { + [Fact] + public void Parse_NonGeneric() { + IArgumentParser parser = new TestParser(); + var input = "".AsSpan(); + + parser.Parse(ref input, new HashSet()).Should().NotBeNull().And.BeOfType(); + } + + private class TestParser : IArgumentParser { + public TestClass Parse(ref ReadOnlySpan input, ISet attributes) => new TestClass(); + } + + private class TestClass { } + } +} diff --git a/tests/TShock.Tests/Commands/Parsers/Attributes/FlagAttributeTests.cs b/tests/TShock.Tests/Commands/Parsers/Attributes/FlagAttributeTests.cs new file mode 100644 index 000000000..707f560f0 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/Attributes/FlagAttributeTests.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Xunit; + +namespace TShock.Commands.Parsers.Attributes { + public class FlagAttributeTests { + [Fact] + public void Ctor_NullFlag_ThrowsArgumentNullException() { + Func func = () => new FlagAttribute(null); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_NullAlternateFlags_ThrowsArgumentNullException() { + Func func = () => new FlagAttribute("", null); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_AlternateFlagsNullElement_ThrowsArgumentException() { + Func func = () => new FlagAttribute("", "test", null); + + func.Should().Throw(); + } + + [Fact] + public void Flags_Get() { + var attribute = new FlagAttribute("test1", "test2", "test3"); + + attribute.Flags.Should().BeEquivalentTo("test1", "test2", "test3"); + } + } +} diff --git a/tests/TShock.Tests/Commands/Parsers/DoubleParserTests.cs b/tests/TShock.Tests/Commands/Parsers/DoubleParserTests.cs new file mode 100644 index 000000000..f05b5d701 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/DoubleParserTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using TShock.Commands.Exceptions; +using Xunit; + +namespace TShock.Commands.Parsers { + public class DoubleParserTests { + [Theory] + [InlineData("1.234", 1.234, "")] + [InlineData("+1.234", 1.234, "")] + [InlineData("000", 0, "")] + [InlineData("-1.234", -1.234, "")] + [InlineData("1.23 test", 1.23, " test")] + [InlineData("1E6", 1E6, "")] + public void Parse(string inputString, double expected, string expectedNextInput) { + var parser = new DoubleParser(); + var input = inputString.AsSpan(); + + parser.Parse(ref input, new HashSet()).Should().Be(expected); + + input.ToString().Should().Be(expectedNextInput); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Parse_MissingDouble_ThrowsParseException(string inputString) { + var parser = new DoubleParser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input, new HashSet()); + }; + + func.Should().Throw(); + } + + [Theory] + [InlineData("aaa")] + [InlineData("123a")] + public void Parse_InvalidDouble_ThrowsParseException(string inputString) { + var parser = new DoubleParser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input, new HashSet()); + }; + func.Should().Throw().WithInnerException(); + } + } +} diff --git a/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs new file mode 100644 index 000000000..ff9751c2c --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/Int32ParserTests.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using TShock.Commands.Exceptions; +using Xunit; + +namespace TShock.Commands.Parsers { + public class Int32ParserTests { + [Theory] + [InlineData("1234", 1234, "")] + [InlineData("+1234", 1234, "")] + [InlineData("000", 0, "")] + [InlineData("-1234", -1234, "")] + [InlineData("123 test", 123, " test")] + public void Parse(string inputString, int expected, string expectedNextInput) { + var parser = new Int32Parser(); + var input = inputString.AsSpan(); + + parser.Parse(ref input, new HashSet()).Should().Be(expected); + + input.ToString().Should().Be(expectedNextInput); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Parse_MissingInteger_ThrowsParseException(string inputString) { + var parser = new Int32Parser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input, new HashSet()); + }; + + func.Should().Throw(); + } + + [Theory] + [InlineData("2147483648")] + [InlineData("-2147483649")] + public void Parse_IntegerOutOfRange_ThrowsParseException(string inputString) { + var parser = new Int32Parser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input, new HashSet()); + }; + + func.Should().Throw().WithInnerException(); + } + + [Theory] + [InlineData("aaa")] + [InlineData("123a")] + [InlineData("123.0")] + public void Parse_InvalidInteger_ThrowsParseException(string inputString) { + var parser = new Int32Parser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input, new HashSet()); + }; + func.Should().Throw().WithInnerException(); + } + } +} diff --git a/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs new file mode 100644 index 000000000..bad2e4ac6 --- /dev/null +++ b/tests/TShock.Tests/Commands/Parsers/StringParserTests.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using TShock.Commands.Exceptions; +using TShock.Commands.Parsers.Attributes; +using Xunit; + +namespace TShock.Commands.Parsers { + public class StringParserTests { + [Theory] + [InlineData("test", "test", "")] + [InlineData("test abc", "test", " abc")] + [InlineData(@"""test""", "test", "")] + [InlineData(@"""test abc def""", "test abc def", "")] + [InlineData(@"""test abc"" def ghi", "test abc", " def ghi")] + [InlineData(@"test\ abc", "test abc", "")] + [InlineData(@"test\ abc def", "test abc", " def")] + [InlineData(@"\\", @"\", "")] + [InlineData(@"\""", @"""", "")] + [InlineData(@"\t", "\t", "")] + [InlineData(@"\n", "\n", "")] + public void Parse(string inputString, string expected, string expectedNextInput) { + var parser = new StringParser(); + var input = inputString.AsSpan(); + + parser.Parse(ref input, new HashSet()).Should().Be(expected); + + input.ToString().Should().Be(expectedNextInput); + } + + [Theory] + [InlineData(@"\")] + [InlineData(@"\a")] + public void Parse_EscapeReachesEnd_ThrowsParseException(string inputString) { + var parser = new StringParser(); + Func func = () => { + var input = inputString.AsSpan(); + return parser.Parse(ref input, new HashSet()); + }; + + func.Should().Throw(); + } + + [Fact] + public void Parse_RestOfInput() { + var parser = new StringParser(); + var input = @"blah blah ""test"" blah blah".AsSpan(); + + parser.Parse(ref input, new HashSet { new RestOfInputAttribute() }) + .Should().Be(@"blah blah ""test"" blah blah"); + + input.ToString().Should().BeEmpty(); + } + } +} diff --git a/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs new file mode 100644 index 000000000..aa73386ae --- /dev/null +++ b/tests/TShock.Tests/Commands/PlayerCommandSenderTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Moq; +using Orion.Packets.World; +using Orion.Players; +using Xunit; + +namespace TShock.Commands { + public class PlayerCommandSenderTests { + private readonly Mock _mockPlayer = new Mock(); + private readonly ICommandSender _sender; + + public PlayerCommandSenderTests() { + _sender = new PlayerCommandSender(_mockPlayer.Object); + } + + [Fact] + public void Ctor_NullPlayer_ThrowsArgumentNullException() { + Func func = () => new PlayerCommandSender(null); + + func.Should().Throw(); + } + + [Fact] + public void Name_Get() { + _mockPlayer.SetupGet(p => p.Name).Returns("test"); + + _sender.Name.Should().Be("test"); + + _mockPlayer.VerifyGet(p => p.Name); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void Player_Get() { + _sender.Player.Should().NotBeNull(); + _sender.Player.Should().Be(_mockPlayer.Object); + } + + [Fact] + public void SendMessage() { + _sender.SendMessage("test"); + + _mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => cp.Color == Color.White && cp.Text == "test"))); + _mockPlayer.VerifyNoOtherCalls(); + } + + [Fact] + public void SendMessage_WithColor() { + _sender.SendMessage("test", Color.Orange); + + _mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => cp.Color == Color.Orange && cp.Text == "test"))); + _mockPlayer.VerifyNoOtherCalls(); + } + } +} diff --git a/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs new file mode 100644 index 000000000..11fe07564 --- /dev/null +++ b/tests/TShock.Tests/Commands/TShockCommandServiceTests.cs @@ -0,0 +1,190 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Moq; +using Orion; +using Orion.Events; +using Serilog.Core; +using TShock.Commands.Parsers; +using TShock.Events.Commands; +using Xunit; + +namespace TShock.Commands { + public class TShockCommandServiceTests { + [Fact] + public void Commands_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + + var commands = commandService.RegisterCommands(testClass).ToList(); + + commandService.Commands.Keys.Should().Contain( + new[] { "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test" }); + commandService.Commands.Values.Should().Contain(commands); + } + + [Fact] + public void Parsers_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var parser = new Mock>().Object; + commandService.RegisterParser(parser); + + commandService.Parsers.Should().Contain(new KeyValuePair(typeof(object), parser)); + } + + [Fact] + public void RegisterCommands() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + + var commands = commandService.RegisterCommands(testClass).ToList(); + + commands.Should().HaveCount(3); + foreach (var command in commands) { + command.QualifiedName.Should().BeOneOf("tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); + } + } + + [Fact] + public void RegisterCommands_NullObj_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + Func> func = () => commandService.RegisterCommands(null); + + func.Should().Throw(); + } + + [Fact] + public void RegisterParser_NullParser_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + Action action = () => commandService.RegisterParser(null); + + action.Should().Throw(); + } + + [Fact] + public void UnregisterCommand() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var commands = commandService.RegisterCommands(testClass).ToList(); + var command = commands[0]; + + commandService.UnregisterCommand(command).Should().BeTrue(); + + commandService.Commands.Keys.Should().NotContain(command.QualifiedName); + commandService.Commands.Values.Should().NotContain(command); + } + + [Fact] + public void UnregisterCommand_CommandDoesntExist_ReturnsFalse() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var mockCommand = new Mock(); + mockCommand.SetupGet(c => c.QualifiedName).Returns("test"); + + commandService.UnregisterCommand(mockCommand.Object).Should().BeFalse(); + } + + [Fact] + public void UnregisterCommand_NullCommand_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + Func func = () => commandService.UnregisterCommand(null); + + func.Should().Throw(); + } + + [Fact] + public void CommandRegister_IsTriggered() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var isRun = false; + var testClass = new TestClass(); + kernel.RegisterHandler(e => { + isRun = true; + e.Command.QualifiedName.Should().BeOneOf( + "tshock_tests:test", "tshock_tests:test2", "tshock_tests2:test"); + }, Logger.None); + + commandService.RegisterCommands(testClass); + + isRun.Should().BeTrue(); + } + + [Fact] + public void CommandRegister_Canceled() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + kernel.RegisterHandler(e => e.Cancel(), Logger.None); + + commandService.RegisterCommands(testClass).Should().BeEmpty(); + } + + [Fact] + public void CommandUnregister_IsTriggered() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var isRun = false; + var testClass = new TestClass(); + var commands = commandService.RegisterCommands(testClass).ToList(); + var command = commands[0]; + kernel.RegisterHandler(e => { + isRun = true; + e.Command.Should().BeSameAs(command); + }, Logger.None); + + commandService.UnregisterCommand(command); + + isRun.Should().BeTrue(); + } + + [Fact] + public void CommandUnregister_Canceled() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var commands = commandService.RegisterCommands(testClass).ToList(); + var command = commands[0]; + kernel.RegisterHandler(e => e.Cancel(), Logger.None); + + commandService.UnregisterCommand(command).Should().BeFalse(); + + commandService.Commands.Values.Should().Contain(command); + } + + private class TestClass { + [CommandHandler("tshock_tests:test")] + public void TestCommand() { } + + [CommandHandler("tshock_tests:test2")] + public void TestCommand2() { } + + [CommandHandler("tshock_tests2:test")] + public void TestCommandTest() { } + } + } +} diff --git a/tests/TShock.Tests/Commands/TShockCommandTests.cs b/tests/TShock.Tests/Commands/TShockCommandTests.cs new file mode 100644 index 000000000..70dbcfb6c --- /dev/null +++ b/tests/TShock.Tests/Commands/TShockCommandTests.cs @@ -0,0 +1,495 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using FluentAssertions; +using Moq; +using Orion; +using Orion.Events; +using Serilog.Core; +using TShock.Commands.Exceptions; +using TShock.Commands.Parsers; +using TShock.Commands.Parsers.Attributes; +using TShock.Events.Commands; +using TShock.Properties; +using Xunit; + +namespace TShock.Commands { + public class TShockCommandTests { + [Fact] + public void QualifiedName_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var attribute = new CommandHandlerAttribute("test"); + var command = new TShockCommand( + commandService, "", + typeof(TShockCommandTests).GetMethod(nameof(QualifiedName_Get)), attribute); + + command.QualifiedName.Should().Be("test"); + } + + [Fact] + public void HelpText_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var attribute = new CommandHandlerAttribute("test") { HelpText = "HelpTest" }; + var command = new TShockCommand( + commandService, "", + typeof(TShockCommandTests).GetMethod(nameof(HelpText_Get)), attribute); + + command.HelpText.Should().Be("HelpTest"); + } + + [Fact] + public void HelpText_GetMissing() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var attribute = new CommandHandlerAttribute("test"); + var command = new TShockCommand( + commandService, "", + typeof(TShockCommandTests).GetMethod(nameof(HelpText_GetMissing)), attribute); + + command.HelpText.Should().Be(Resources.Command_MissingHelpText); + } + + [Fact] + public void UsageText_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var attribute = new CommandHandlerAttribute("test") { UsageText = "UsageTest" }; + var command = new TShockCommand( + commandService, "", + typeof(TShockCommandTests).GetMethod(nameof(UsageText_Get)), attribute); + + command.UsageText.Should().Be("UsageTest"); + } + + [Fact] + public void UsageText_GetMissing() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var attribute = new CommandHandlerAttribute("test"); + var command = new TShockCommand( + commandService, "", + typeof(TShockCommandTests).GetMethod(nameof(UsageText_GetMissing)), attribute); + + command.UsageText.Should().Be(Resources.Command_MissingUsageText); + } + + [Fact] + public void ShouldBeLogged_Get() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var attribute = new CommandHandlerAttribute("test") { ShouldBeLogged = false }; + var command = new TShockCommand( + commandService, "", + typeof(TShockCommandTests).GetMethod(nameof(ShouldBeLogged_Get)), attribute); + + command.ShouldBeLogged.Should().Be(false); + } + + [Fact] + public void Invoke_Sender() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, ""); + + testClass.Sender.Should().BeSameAs(commandSender); + } + + [Theory] + [InlineData("1 test", 1, "test")] + [InlineData(@"-56872 ""test abc\"" def""", -56872, "test abc\" def")] + public void Invoke_SenderIntString(string input, int expectedInt, string expectedString) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Int_String)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.Int.Should().Be(expectedInt); + testClass.String.Should().Be(expectedString); + } + + [Theory] + [InlineData("", false, false)] + [InlineData(" -x", true, false)] + [InlineData("--xxx ", true, false)] + [InlineData("-y ", false, true)] + [InlineData(" --yyy", false, true)] + [InlineData(" -xy ", true, true)] + [InlineData(" -x --yyy", true, true)] + [InlineData("--xxx --yyy", true, true)] + public void Invoke_Flags(string input, bool expectedX, bool expectedY) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Flags)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.X.Should().Be(expectedX); + testClass.Y.Should().Be(expectedY); + } + + [Theory] + [InlineData("1", 1, 1234, 5678)] + [InlineData(" 1 ", 1, 1234, 5678)] + [InlineData("1 2", 1, 2, 5678)] + [InlineData("1 2 3", 1, 2, 3)] + [InlineData("--val=9001 1", 1, 9001, 5678)] + [InlineData(" --val=9001 1", 1, 9001, 5678)] + [InlineData("--val2=5678 1", 1, 1234, 5678)] + [InlineData(" --val2=5678 1", 1, 1234, 5678)] + public void Invoke_Optionals(string input, int expectedRequired, int expectedVal, int expectedVal2) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Optionals)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.Required.Should().Be(expectedRequired); + testClass.Val.Should().Be(expectedVal); + testClass.Val2.Should().Be(expectedVal2); + } + + [Theory] + [InlineData("", false, false, 10)] + [InlineData("-f", true, false, 10)] + [InlineData("--force", true, false, 10)] + [InlineData("-r", false, true, 10)] + [InlineData("--recursive", false, true, 10)] + [InlineData("-fr", true, true, 10)] + [InlineData("-rf", true, true, 10)] + [InlineData("-f --recursive", true, true, 10)] + [InlineData("-r --force", true, true, 10)] + [InlineData("--recursive --force", true, true, 10)] + [InlineData("--depth=1 --recursive -f", true, true, 1)] + [InlineData("--force -r --depth=100 ", true, true, 100)] + [InlineData("--force -r --depth= 100 ", true, true, 100)] + public void Invoke_FlagsAndOptionals( + string input, bool expectedForce, bool expectedRecursive, int expectedDepth) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.Force.Should().Be(expectedForce); + testClass.Recursive.Should().Be(expectedRecursive); + testClass.Depth.Should().Be(expectedDepth); + } + + [Theory] + [InlineData("")] + [InlineData("1", 1)] + [InlineData("1 2", 1, 2)] + [InlineData(" -1 2 -5", -1, 2, -5)] + public void Invoke_Params(string input, params int[] expectedInts) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Params)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, input); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.Ints.Should().BeEquivalentTo(expectedInts); + } + + [Fact] + public void Invoke_OptionalGetsRenamed() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + commandService.RegisterParser(new Int32Parser()); + commandService.RegisterParser(new StringParser()); + + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_OptionalRename)); + var commandSender = new Mock().Object; + + command.Invoke(commandSender, "--hyphenated-optional-is-long=60"); + + testClass.Sender.Should().BeSameAs(commandSender); + testClass.HyphenatedOptionalIsLong.Should().Be(60); + } + + [Fact] + public void Invoke_TriggersCommandExecute() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); + var commandSender = new Mock().Object; + + var isRun = false; + kernel.RegisterHandler(e => { + isRun = true; + e.Command.Should().Be(command); + e.Input.Should().BeEmpty(); + }, Logger.None); + + command.Invoke(commandSender, ""); + + testClass.Sender.Should().BeSameAs(commandSender); + isRun.Should().BeTrue(); + } + + [Fact] + public void Invoke_CommandExecuteCanceled_IsCanceled() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); + var commandSender = new Mock().Object; + kernel.RegisterHandler(e => e.Cancel(), Logger.None); + + command.Invoke(commandSender, "failing input"); + } + + [Theory] + [InlineData("1 ")] + [InlineData("-7345734 ")] + public void Invoke_MissingArg_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Int_String)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, input); + + action.Should().Throw(); + } + + [Theory] + [InlineData("-xyz")] + [InlineData("-z")] + public void Invoke_UnexpectedShortFlag_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Flags)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, input); + + action.Should().Throw(); + } + + [Theory] + [InlineData("--this-is-not-ok")] + [InlineData("--neither-is-this")] + public void Invoke_UnexpectedLongFlag_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Flags)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, input); + + action.Should().Throw(); + } + + [Theory] + [InlineData("--required=123")] + [InlineData("--not-ok=test")] + public void Invoke_UnexpectedOptional_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Optionals)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, input); + + action.Should().Throw(); + } + + [Theory] + [InlineData("-")] + [InlineData("- ")] + [InlineData("--")] + [InlineData("-- ")] + [InlineData("--= ")] + public void Invoke_InvalidHyphenatedArgs_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_FlagsAndOptionals)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, input); + + action.Should().Throw(); + } + + [Fact] + public void Invoke_UnexpectedArgType_ThrowsCommandParseException() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_NoTestClass)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, ""); + + action.Should().Throw(); + } + + [Theory] + [InlineData("a")] + [InlineData("bcd")] + public void Invoke_TooManyArguments_ThrowsCommandParseException(string input) { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, input); + + action.Should().Throw(); + } + + [Fact] + public void Invoke_ThrowsException_ThrowsCommandException() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand_Exception)); + var commandSender = new Mock().Object; + Action action = () => command.Invoke(commandSender, ""); + + action.Should().Throw().WithInnerException(); + } + + [Fact] + public void Invoke_NullSender_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + using var commandService = new TShockCommandService(kernel, Logger.None); + var testClass = new TestClass(); + var command = GetCommand(commandService, testClass, nameof(TestClass.TestCommand)); + Action action = () => command.Invoke(null, ""); + + action.Should().Throw(); + } + + private ICommand GetCommand(TShockCommandService commandService, TestClass testClass, string methodName) { + var handler = typeof(TestClass).GetMethod(methodName); + var attribute = handler.GetCustomAttribute(); + return new TShockCommand(commandService, testClass, handler, attribute); + } + + [SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Testing")] + private class TestClass { + public ICommandSender Sender { get; private set; } + public int Int { get; private set; } + public string String { get; private set; } + public bool X { get; private set; } + public bool Y { get; private set; } + public int Required { get; private set; } + public int Val { get; private set; } + public int Val2 { get; private set; } + public bool Force { get; private set; } + public bool Recursive { get; private set; } + public int Depth { get; private set; } + public int HyphenatedOptionalIsLong { get; private set; } + public int[] Ints { get; private set; } + + [CommandHandler("tshock_tests:test")] + public void TestCommand(ICommandSender sender) => Sender = sender; + + [CommandHandler("tshock_tests:test_int_string")] + public void TestCommand_Int_String(ICommandSender sender, int @int, string @string) { + Sender = sender; + Int = @int; + String = @string; + } + + [CommandHandler("tshock_tests:test_flags")] + public void TestCommand_Flags(ICommandSender sender, [Flag("x", "xxx")] bool x, [Flag("y", "yyy")] bool y) { + Sender = sender; + X = x; + Y = y; + } + + [CommandHandler("tshock_tests:test_optionals")] + public void TestCommand_Optionals(ICommandSender sender, int required, int val = 1234, int val2 = 5678) { + Sender = sender; + Required = required; + Val = val; + Val2 = val2; + } + + [CommandHandler("tshock_tests:test_flags_and_optionals")] + public void TestCommand_FlagsAndOptionals( + ICommandSender sender, [Flag("f", "force")] bool force, [Flag("r", "recursive")] bool recursive, + int depth = 10) { + Sender = sender; + Force = force; + Recursive = recursive; + Depth = depth; + } + + [CommandHandler("tshock_tests:test_optional_rename")] + public void TestCommand_OptionalRename(ICommandSender sender, int hyphenated_optional_is_long = 100) { + Sender = sender; + HyphenatedOptionalIsLong = hyphenated_optional_is_long; + } + + [CommandHandler("tshock_tests:test_params")] + public void TestCommand_Params(ICommandSender sender, params int[] ints) { + Sender = sender; + Ints = ints; + } + + [CommandHandler("tshock_tests:exception")] + public void TestCommand_Exception(ICommandSender sender) => throw new NotImplementedException(); + + [CommandHandler("tshock_tests:test_no_testclass")] + public void TestCommand_NoTestClass(ICommandSender sender, TestClass testClass) { } + } + } +} diff --git a/tests/TShock.Tests/Events/Commands/CommandEventTests.cs b/tests/TShock.Tests/Events/Commands/CommandEventTests.cs new file mode 100644 index 000000000..89c4262cd --- /dev/null +++ b/tests/TShock.Tests/Events/Commands/CommandEventTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Moq; +using TShock.Commands; +using Xunit; + +namespace TShock.Events.Commands { + public class CommandEventTests { + [Fact] + public void Ctor_NullCommand_ThrowsArgumentNullException() { + Func func = () => new TestCommandEvent(null); + + func.Should().Throw(); + } + + [Fact] + public void Command_Get() { + var command = new Mock().Object; + var e = new TestCommandEvent(command); + + e.Command.Should().BeSameAs(command); + } + + [Fact] + public void Command_SetNullValue_ThrowsArgumentNullException() { + var command = new Mock().Object; + var e = new TestCommandEvent(command); + Action action = () => e.Command = null; + + action.Should().Throw(); + } + + private class TestCommandEvent : CommandEvent { + public TestCommandEvent(ICommand command) : base(command) { } + } + } +} diff --git a/tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs b/tests/TShock.Tests/Events/Commands/CommandExecuteEventTests.cs similarity index 66% rename from tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs rename to tests/TShock.Tests/Events/Commands/CommandExecuteEventTests.cs index 8f67dfc1e..f2995dae4 100644 --- a/tests/TShock.Tests/Events/Commands/CommandExecuteEventArgsTests.cs +++ b/tests/TShock.Tests/Events/Commands/CommandExecuteEventTests.cs @@ -22,11 +22,19 @@ using Xunit; namespace TShock.Events.Commands { - public class CommandExecuteEventArgsTests { + public class CommandExecuteEventTests { + [Fact] + public void Ctor_NullCommand_ThrowsArgumentNullException() { + var sender = new Mock().Object; + Func func = () => new CommandExecuteEvent(null, sender, ""); + + func.Should().Throw(); + } + [Fact] public void Ctor_NullSender_ThrowsArgumentNullException() { var command = new Mock().Object; - Func func = () => new CommandExecuteEventArgs(command, null, ""); + Func func = () => new CommandExecuteEvent(command, null, ""); func.Should().Throw(); } @@ -35,35 +43,35 @@ public void Ctor_NullSender_ThrowsArgumentNullException() { public void Ctor_NullInput_ThrowsArgumentNullException() { var command = new Mock().Object; var sender = new Mock().Object; - Func func = () => new CommandExecuteEventArgs(command, sender, null); + Func func = () => new CommandExecuteEvent(command, sender, null); func.Should().Throw(); } [Fact] - public void Sender_Get_IsCorrect() { + public void Sender_Get() { var command = new Mock().Object; var sender = new Mock().Object; - var args = new CommandExecuteEventArgs(command, sender, ""); + var e = new CommandExecuteEvent(command, sender, ""); - args.Sender.Should().BeSameAs(sender); + e.Sender.Should().BeSameAs(sender); } [Fact] - public void Input_Get_IsCorrect() { + public void Input_Get() { var command = new Mock().Object; var sender = new Mock().Object; - var args = new CommandExecuteEventArgs(command, sender, "test"); + var e = new CommandExecuteEvent(command, sender, "test"); - args.Input.Should().Be("test"); + e.Input.Should().Be("test"); } [Fact] public void Input_SetNullValue_ThrowsArgumentNullException() { var command = new Mock().Object; var sender = new Mock().Object; - var args = new CommandExecuteEventArgs(command, sender, ""); - Action action = () => args.Input = null; + var e = new CommandExecuteEvent(command, sender, ""); + Action action = () => e.Input = null; action.Should().Throw(); } diff --git a/tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs b/tests/TShock.Tests/Events/Commands/CommandRegisterEventTests.cs similarity index 78% rename from tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs rename to tests/TShock.Tests/Events/Commands/CommandRegisterEventTests.cs index fb4d6b856..6400887e3 100644 --- a/tests/TShock.Tests/Events/Commands/CommandEventArgsTests.cs +++ b/tests/TShock.Tests/Events/Commands/CommandRegisterEventTests.cs @@ -17,20 +17,18 @@ using System; using FluentAssertions; +using Moq; using TShock.Commands; using Xunit; namespace TShock.Events.Commands { - public class CommandEventArgsTests { + public class CommandRegisterEventTests { [Fact] public void Ctor_NullCommand_ThrowsArgumentNullException() { - Func func = () => new TestCommandEventArgs(null); + var sender = new Mock().Object; + Func func = () => new CommandRegisterEvent(null); func.Should().Throw(); } - - private class TestCommandEventArgs : CommandEventArgs { - public TestCommandEventArgs(ICommand command) : base(command) { } - } } } diff --git a/src/TShock/Events/Commands/CommandEventArgs.cs b/tests/TShock.Tests/Events/Commands/CommandUnregisterEventTests.cs similarity index 60% rename from src/TShock/Events/Commands/CommandEventArgs.cs rename to tests/TShock.Tests/Events/Commands/CommandUnregisterEventTests.cs index a95c8ab35..8f088e939 100644 --- a/src/TShock/Events/Commands/CommandEventArgs.cs +++ b/tests/TShock.Tests/Events/Commands/CommandUnregisterEventTests.cs @@ -16,24 +16,19 @@ // along with TShock. If not, see . using System; -using Orion.Events; +using FluentAssertions; +using Moq; using TShock.Commands; +using Xunit; namespace TShock.Events.Commands { - /// - /// Provides data for command-related events. - /// - public abstract class CommandEventArgs : EventArgs, ICancelable { - /// - public string? CancellationReason { get; set; } + public class CommandUnregisterEventTests { + [Fact] + public void Ctor_NullCommand_ThrowsArgumentNullException() { + var sender = new Mock().Object; + Func func = () => new CommandUnregisterEvent(null); - /// - /// Gets or sets the command. - /// - public ICommand Command { get; set; } - - private protected CommandEventArgs(ICommand command) { - Command = command ?? throw new ArgumentNullException(nameof(command)); + func.Should().Throw(); } } } diff --git a/tests/TShock.Tests/Logging/Formatting/ExceptionFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/ExceptionFormatterTests.cs new file mode 100644 index 000000000..b36dee8ff --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/ExceptionFormatterTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class ExceptionFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Exception] = new Color(0xff, 0x00, 0x00) + }); + + [Fact] + public void Format() { + var formatter = new ExceptionFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, + new Exception("TEST"), + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff0000:System.Exception: TEST]"); + } + + [Fact] + public void Format_NoException() { + var formatter = new ExceptionFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().BeEmpty(); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/LevelFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/LevelFormatterTests.cs new file mode 100644 index 000000000..a11b3355a --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/LevelFormatterTests.cs @@ -0,0 +1,131 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class LevelFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.VerboseLevel] = new Color(0x00, 0x00, 0xff), + [PlayerLogThemeStyle.DebugLevel] = new Color(0x00, 0xff, 0x00), + [PlayerLogThemeStyle.InformationLevel] = new Color(0x00, 0xff, 0xff), + [PlayerLogThemeStyle.WarningLevel] = new Color(0xff, 0x00, 0x00), + [PlayerLogThemeStyle.ErrorLevel] = new Color(0xff, 0x00, 0xff), + [PlayerLogThemeStyle.FatalLevel] = new Color(0xff, 0xff, 0x00), + [PlayerLogThemeStyle.Invalid] = new Color(0x00, 0x00, 0x00) + }); + + [Fact] + public void Format_Verbose() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Verbose, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/0000ff:VRB]"); + } + + [Fact] + public void Format_Debug() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/00ff00:DBG]"); + } + + [Fact] + public void Format_Information() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Information, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/00ffff:INF]"); + } + + [Fact] + public void Format_Warning() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Warning, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff0000:WRN]"); + } + + [Fact] + public void Format_Error() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Error, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff00ff:ERR]"); + } + + [Fact] + public void Format_Fatal() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Fatal, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ffff00:FTL]"); + } + + [Fact] + public void Format_Unknown() { + var formatter = new LevelFormatter(_theme); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, (LogEventLevel)(-1), null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/000000:???]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/MessageTemplateFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/MessageTemplateFormatterTests.cs new file mode 100644 index 000000000..1e2d5a0a0 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/MessageTemplateFormatterTests.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using Serilog.Parsing; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class MessageTemplateFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Invalid] = new Color(0xff, 0x00, 0x00) + }); + + [Fact] + public void Format_Text() { + var formatter = new MessageTemplateFormatter(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + new MessageTemplate(new[] { new TextToken("Test") }), + Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("Test"); + } + + [Fact] + public void Format_Property() { + var formatter = new MessageTemplateFormatter(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + new MessageTemplate(new[] { new PropertyToken("Test", "{Test}") }), + new[] { new LogEventProperty("Test", new ScalarValue(1)) }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("1"); + } + + [Fact] + public void Format_InvalidProperty() { + var formatter = new MessageTemplateFormatter(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + new MessageTemplate(new[] { new PropertyToken("Test", "{Test}") }), + Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff0000:{Test}]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/NewLineFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/NewLineFormatterTests.cs new file mode 100644 index 000000000..05b1a1e3a --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/NewLineFormatterTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.IO; +using System.Linq; +using FluentAssertions; +using Serilog.Events; +using Xunit; + +namespace TShock.Logging.Formatting { + public class NewLineFormatterTests { + [Fact] + public void Format() { + var formatter = new NewLineFormatter(); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("\n"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/PlayerLogFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/PlayerLogFormatterTests.cs new file mode 100644 index 000000000..6dafb1e82 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/PlayerLogFormatterTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using Serilog.Parsing; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class PlayerLogFormatterTests { + [Fact] + public void Format() { + var theme = new PlayerLogTheme(new Dictionary()); + var formatter = new PlayerLogFormatter(theme, + "[{Timestamp:HH:mm:ss} {Level}] {Property1}{Message}{NewLine}{Exception}", + CultureInfo.InvariantCulture); + var logEvent = new LogEvent( + new DateTimeOffset(new DateTime(2019, 10, 10, 9, 59, 59)), LogEventLevel.Debug, + new Exception("TEST"), + new MessageTemplate(new[] { new TextToken("my message") }), + new[] { + new LogEventProperty("Property1", new ScalarValue(1)) + }); + var writer = new StringWriter(); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[09:59:59 DBG] 1my message\nSystem.Exception: TEST"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs new file mode 100644 index 000000000..dc864d6d0 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/PropertiesFormatterTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using Serilog.Parsing; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class PropertiesFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary()); + + [Fact] + public void Format() { + var formatter = new PropertiesFormatter(_theme, MessageTemplate.Empty, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, + new[] { + new LogEventProperty("Test", new ScalarValue(1)), + new LogEventProperty("Test2", new ScalarValue(2)), + }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("{Test=1, Test2=2}"); + } + + [Fact] + public void Format_ExistsInOutputTemplate() { + var formatter = new PropertiesFormatter(_theme, + new MessageTemplate(new[] { new PropertyToken("Test2", "{Test2}") }), CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, + new[] { + new LogEventProperty("Test", new ScalarValue(1)), + new LogEventProperty("Test2", new ScalarValue(2)), + }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("{Test=1}"); + } + + [Fact] + public void Format_ExistsInTemplate() { + var formatter = new PropertiesFormatter(_theme, MessageTemplate.Empty, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + new MessageTemplate(new[] { new PropertyToken("Test", "{Test}") }), + new[] { + new LogEventProperty("Test", new ScalarValue(1)), + new LogEventProperty("Test2", new ScalarValue(2)), + }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("{Test2=2}"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/PropertyFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/PropertyFormatterTests.cs new file mode 100644 index 000000000..5c940b376 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/PropertyFormatterTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using Serilog.Parsing; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class PropertyFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Invalid] = new Color(0xff, 0x00, 0x00) + }); + + [Fact] + public void Format() { + var formatter = new PropertyFormatter(_theme, new PropertyToken("Test", "{Test}"), + CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, MessageTemplate.Empty, + new[] { new LogEventProperty("Test", new ScalarValue(1)) }); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("1"); + } + + [Fact] + public void Format_InvalidProperty() { + var formatter = new PropertyFormatter(_theme, new PropertyToken("Test", "{Test}"), + CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ff0000:{Test}]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/TextFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/TextFormatterTests.cs new file mode 100644 index 000000000..b23dad195 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/TextFormatterTests.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class TextFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Text] = new Color(0xff, 0xff, 0x00) + }); + + [Fact] + public void Format_Text() { + var formatter = new TextFormatter(_theme, "test"); + var writer = new StringWriter(); + var logEvent = new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/ffff00:test]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/Formatting/TimestampFormatterTests.cs b/tests/TShock.Tests/Logging/Formatting/TimestampFormatterTests.cs new file mode 100644 index 000000000..73d8435a1 --- /dev/null +++ b/tests/TShock.Tests/Logging/Formatting/TimestampFormatterTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging.Formatting { + public class TimestampFormatterTests { + private readonly PlayerLogTheme _theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Timestamp] = new Color(0xfa, 0xdf, 0xad) + }); + + [Fact] + public void Format() { + var formatter = new TimestampFormatter(_theme, "yyyyMMdd", CultureInfo.InvariantCulture); + var writer = new StringWriter(); + var logEvent = new LogEvent( + new DateTimeOffset(new DateTime(2019, 10, 10)), LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty()); + + formatter.Format(logEvent, writer); + + writer.ToString().Should().Be("[c/fadfad:20191010]"); + } + } +} diff --git a/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs b/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs new file mode 100644 index 000000000..0fbb3996a --- /dev/null +++ b/tests/TShock.Tests/Logging/PlayerLogSinkTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.IO; +using System.Linq; +using Microsoft.Xna.Framework; +using Moq; +using Orion.Packets.World; +using Orion.Players; +using Serilog.Events; +using Serilog.Formatting; +using Xunit; + +namespace TShock.Logging { + public class PlayerLogSinkTests { + [Fact] + public void Emit() { + var mockPlayer = new Mock(); + var mockFormatter = new Mock(); + mockFormatter + .Setup(f => f.Format(It.IsAny(), It.IsAny())) + .Callback((LogEvent logEvent, TextWriter output) => output.Write("TEST")); + var sink = new PlayerLogSink(mockPlayer.Object, mockFormatter.Object); + + sink.Emit(new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty())); + + mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => cp.Color == Color.White && cp.Text == "TEST"))); + } + + [Fact] + public void Emit_EndsInNewLine() { + var mockPlayer = new Mock(); + var mockFormatter = new Mock(); + mockFormatter + .Setup(f => f.Format(It.IsAny(), It.IsAny())) + .Callback((LogEvent logEvent, TextWriter output) => output.Write("TEST\n")); + var sink = new PlayerLogSink(mockPlayer.Object, mockFormatter.Object); + + sink.Emit(new LogEvent( + DateTimeOffset.Now, LogEventLevel.Debug, null, + MessageTemplate.Empty, Enumerable.Empty())); + + mockPlayer.Verify(p => p.SendPacket( + It.Is(cp => cp.Color == Color.White && cp.Text == "TEST"))); + } + } +} diff --git a/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs b/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs new file mode 100644 index 000000000..a4fe994be --- /dev/null +++ b/tests/TShock.Tests/Logging/PlayerLogValueVisitorTests.cs @@ -0,0 +1,185 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Serilog.Events; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging { + public class PlayerLogValueVisitorTests { + private readonly PlayerLogTheme _theme = + new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Null] = new Color(0x00, 0x00, 0x00), + [PlayerLogThemeStyle.String] = new Color(0x00, 0x80, 0x00), + [PlayerLogThemeStyle.Boolean] = new Color(0x00, 0x00, 0x80), + [PlayerLogThemeStyle.Character] = new Color(0x00, 0x00, 0xff), + [PlayerLogThemeStyle.Number] = new Color(0xff, 0xff, 0x00), + [PlayerLogThemeStyle.Scalar] = new Color(0x00, 0x00, 0x01), + [PlayerLogThemeStyle.Separator] = new Color(0xc0, 0xc0, 0xc0), + [PlayerLogThemeStyle.Identifier] = new Color(0xff, 0x00, 0x00), + [PlayerLogThemeStyle.Type] = new Color(0xff, 0x00, 0xff) + }); + + [Fact] + public void Format_Null() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue(null), writer); + + writer.ToString().Should().Be("[c/000000:null]"); + } + + [Fact] + public void Format_String() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue("test"), writer); + + writer.ToString().Should().Be("[c/008000:test]"); + } + + [Fact] + public void Format_Bool() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue(true), writer); + + writer.ToString().Should().Be("[c/000080:True]"); + } + + [Fact] + public void Format_Char() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue('t'), writer); + + writer.ToString().Should().Be("[c/0000ff:t]"); + } + + [Fact] + public void Format_Number() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue(-12345), writer); + + writer.ToString().Should().Be("[c/ffff00:-12345]"); + } + + [Fact] + public void Format_Scalar() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new ScalarValue(new StringBuilder("test")), writer); + + writer.ToString().Should().Be("[c/000001:test]"); + } + + [Fact] + public void Format_Sequence() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new SequenceValue(new[] { new ScalarValue(1), new ScalarValue(2) }), writer); + + writer.ToString().Should().Be("[[c/ffff00:1][c/c0c0c0:, ][c/ffff00:2]]"); + } + + [Fact] + public void Format_Sequence_NoSeparator() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new SequenceValue(new[] { new ScalarValue(1) }), writer); + + writer.ToString().Should().Be("[[c/ffff00:1]]"); + } + + [Fact] + public void Format_Structure() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) }), writer); + + writer.ToString().Should().Be("{[c/ff0000:Test][c/c0c0c0:=][c/ffff00:1]}"); + } + + [Fact] + public void Format_Structure_WithSeparator() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new StructureValue(new[] { + new LogEventProperty("Test", new ScalarValue(1)), + new LogEventProperty("Test2", new ScalarValue(2)) + }), writer); + + writer.ToString().Should().Be( + "{[c/ff0000:Test][c/c0c0c0:=][c/ffff00:1][c/c0c0c0:, ][c/ff0000:Test2][c/c0c0c0:=][c/ffff00:2]}"); + } + + [Fact] + public void Format_Structure_WithTypeTag() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format( + new StructureValue(new[] { new LogEventProperty("Test", new ScalarValue(1)) }, "Type"), + writer); + + writer.ToString().Should().Be("[c/ff00ff:Type]{[c/ff0000:Test][c/c0c0c0:=][c/ffff00:1]}"); + } + + [Fact] + public void Format_Dictionary() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new DictionaryValue(new[] { + new KeyValuePair(new ScalarValue(1), new ScalarValue("test")) + }), writer); + + writer.ToString().Should().Be("{[[c/ffff00:1]][c/c0c0c0:=][c/008000:test]}"); + } + + [Fact] + public void Format_Dictionary_WithSeparator() { + var visitor = new PlayerLogValueVisitor(_theme, CultureInfo.InvariantCulture); + var writer = new StringWriter(); + + visitor.Format(new DictionaryValue(new[] { + new KeyValuePair(new ScalarValue(1), new ScalarValue("test")), + new KeyValuePair(new ScalarValue(2), new ScalarValue("test2")) + }), writer); + + writer.ToString().Should().Be( + "{[[c/ffff00:1]][c/c0c0c0:=][c/008000:test][c/c0c0c0:, ][[c/ffff00:2]][c/c0c0c0:=][c/008000:test2]}"); + } + } +} diff --git a/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs b/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs new file mode 100644 index 000000000..a34026965 --- /dev/null +++ b/tests/TShock.Tests/Logging/PlayerLoggerConfigurationExtensionsTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Moq; +using Orion.Packets.World; +using Orion.Players; +using Serilog; +using TShock.Logging.Themes; +using Xunit; + +namespace TShock.Logging { + public class PlayerLoggerConfigurationExtensionsTests { + [Fact] + public void Player() { + var theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Text] = new Color(0x12, 0x34, 0x56) + }); + var mockPlayer = new Mock(); + var logger = new LoggerConfiguration() + .WriteTo.Player(mockPlayer.Object, theme: theme) + .CreateLogger(); + + logger.Error("FAIL"); + + mockPlayer.Verify(p => p.SendPacket(It.Is(cp => cp.Text.Contains("[c/123456:FAIL]")))); + } + + [Fact] + public void Player_NullTheme() { + var mockPlayer = new Mock(); + var logger = new LoggerConfiguration() + .WriteTo.Player(mockPlayer.Object) + .CreateLogger(); + + logger.Error("FAIL"); + + mockPlayer.Verify(p => p.SendPacket(It.Is(cp => cp.Text.Contains("[c/dcdcdc:FAIL]")))); + } + + [Fact] + public void Player_NullConfiguration_ThrowsArgumentNullException() { + var player = new Mock().Object; + Func func = () => PlayerLoggerConfigurationExtensions.Player(null, player); + + func.Should().Throw(); + } + + [Fact] + public void Player_NullPlayer_ThrowsArgumentNullException() { + var configuration = new LoggerConfiguration(); + Func func = () => configuration.WriteTo.Player(null); + + func.Should().Throw(); + } + + [Fact] + public void Player_NullOutputTemplate_ThrowsArgumentNullException() { + var configuration = new LoggerConfiguration(); + var player = new Mock().Object; + Func func = () => configuration.WriteTo.Player(player, outputTemplate: null); + + func.Should().Throw(); + } + } +} diff --git a/tests/TShock.Tests/Logging/Themes/PlayerLogThemeTests.cs b/tests/TShock.Tests/Logging/Themes/PlayerLogThemeTests.cs new file mode 100644 index 000000000..78b1e2fa7 --- /dev/null +++ b/tests/TShock.Tests/Logging/Themes/PlayerLogThemeTests.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Xunit; + +namespace TShock.Logging.Themes { + public class PlayerLogThemeTests { + [Fact] + public void Stylize() { + var theme = new PlayerLogTheme(new Dictionary { + [PlayerLogThemeStyle.Invalid] = new Color(0x65, 0x43, 0x21) + }); + + theme.Stylize("test", PlayerLogThemeStyle.Invalid).Should().Be("[c/654321:test]"); + } + + [Fact] + public void Stylize_MissingStyle() { + var theme = new PlayerLogTheme(new Dictionary()); + + theme.Stylize("test", PlayerLogThemeStyle.Text).Should().Be("test"); + } + } +} diff --git a/tests/TShock.Tests/TShock.Tests.csproj b/tests/TShock.Tests/TShock.Tests.csproj index 1648d3fe4..1a3c554a1 100644 --- a/tests/TShock.Tests/TShock.Tests.csproj +++ b/tests/TShock.Tests/TShock.Tests.csproj @@ -2,10 +2,9 @@ netcoreapp3.0 - false - TShock + true diff --git a/tests/TShock.Tests/TShockPluginTests.cs b/tests/TShock.Tests/TShockPluginTests.cs new file mode 100644 index 000000000..fa389a626 --- /dev/null +++ b/tests/TShock.Tests/TShockPluginTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Moq; +using Orion; +using Orion.Events.Server; +using Orion.Players; +using Serilog.Core; +using TShock.Commands; +using TShock.Modules; +using Xunit; + +namespace TShock { + public class TShockPluginTests { + [Fact] + public void Ctor_NullKernel_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + Func func = () => new TShockPlugin(null, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_NullPlayerService_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + Func func = () => new TShockPlugin(kernel, Logger.None, + null, + new Lazy(() => mockCommandService.Object)); + + func.Should().Throw(); + } + + [Fact] + public void Ctor_NullCommandService_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + Func func = () => new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + null); + + func.Should().Throw(); + } + + [Fact] + public void RegisterModule_NullModule_ThrowsArgumentNullException() { + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + Action action = () => plugin.RegisterModule(null); + + action.Should().Throw(); + } + + [Fact] + public void Dispose_DisposesModule() { + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + var module = new TestModule(); + plugin.RegisterModule(module); + + plugin.Dispose(); + + module.IsDisposed.Should().BeTrue(); + } + + [Fact] + public void ServerInitialize_InitializesModule() { + using var kernel = new OrionKernel(Logger.None); + var mockPlayerService = new Mock(); + var mockCommandService = new Mock(); + using var plugin = new TShockPlugin(kernel, Logger.None, + new Lazy(() => mockPlayerService.Object), + new Lazy(() => mockCommandService.Object)); + var module = new TestModule(); + plugin.Initialize(); + plugin.RegisterModule(module); + + kernel.RaiseEvent(new ServerInitializeEvent(), Logger.None); + + module.IsInitialized.Should().BeTrue(); + } + + private class TestModule : TShockModule { + public bool IsDisposed { get; private set; } + public bool IsInitialized { get; private set; } + + public override void Dispose() => IsDisposed = true; + public override void Initialize() => IsInitialized = true; + } + } +} diff --git a/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs new file mode 100644 index 000000000..a3291ef12 --- /dev/null +++ b/tests/TShock.Tests/Utils/Extensions/ColorExtensionsTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Xunit; + +namespace TShock.Utils.Extensions { + public class ColorExtensionsTests { + public static readonly IEnumerable ToHexStringData = new List { + new object[] { new Color(0xf3, 0x20, 0x00), "f32000" }, + new object[] { new Color(0x01, 0x02, 0x03), "010203" } + }; + + [Theory] + [MemberData(nameof(ToHexStringData))] + public void ToHexString(Color color, string expected) => color.ToHexString().Should().Be(expected); + } +} diff --git a/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs new file mode 100644 index 000000000..cf920ffdb --- /dev/null +++ b/tests/TShock.Tests/Utils/Extensions/DictionaryExtensionsTests.cs @@ -0,0 +1,64 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Xunit; + +namespace TShock.Utils.Extensions { + public class DictionaryExtensionsTests { + [Fact] + public void GetValueOrDefault() { + var dictionary = new Dictionary(); + + dictionary.GetValueOrDefault("test").Should().Be(0); + } + + [Fact] + public void GetValueOrDefault_KeyExists() { + var dictionary = new Dictionary { + ["test"] = 10 + }; + + dictionary.GetValueOrDefault("test", () => 50).Should().Be(10); + } + + [Fact] + public void GetValueOrDefault_KeyDoesntExist() { + var dictionary = new Dictionary(); + + dictionary.GetValueOrDefault("test", () => 50).Should().Be(50); + } + + [Fact] + public void GetValueOrDefault_Create() { + var dictionary = new Dictionary(); + + dictionary.GetValueOrDefault("test", () => 50, true).Should().Be(50); + + dictionary["test"].Should().Be(50); + } + + [Fact] + public void GetValueOrDefault_NullDictionary_ThrowsArgumentNullException() { + Func func = () => DictionaryExtensions.GetValueOrDefault(null, "", () => 0); + + func.Should().Throw(); + } + } +} diff --git a/tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs new file mode 100644 index 000000000..3061a9422 --- /dev/null +++ b/tests/TShock.Tests/Utils/Extensions/ReadOnlySpanExtensionsTests.cs @@ -0,0 +1,30 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Xunit; + +namespace TShock.Utils.Extensions { + public class ReadOnlySpanExtensionsTests { + [Fact] + public void IndexOfOrEnd() => "abcde".AsSpan().IndexOfOrEnd('b').Should().Be(1); + + [Fact] + public void IndexOfOrEnd_AtEnd() => "abcde".AsSpan().IndexOfOrEnd('f').Should().Be(5); + } +} diff --git a/tests/TShock.Tests/Utils/Extensions/StringExtensionsTests.cs b/tests/TShock.Tests/Utils/Extensions/StringExtensionsTests.cs new file mode 100644 index 000000000..f059dfed6 --- /dev/null +++ b/tests/TShock.Tests/Utils/Extensions/StringExtensionsTests.cs @@ -0,0 +1,35 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Microsoft.Xna.Framework; +using Xunit; + +namespace TShock.Utils.Extensions { + public class StringExtensionsTests { + [Fact] + public void WithColor() => "test".WithColor(new Color(0x12, 0x34, 0x56)).Should().Be("[c/123456:test]"); + + [Fact] + public void WithColor_NullStr_ThrowsArgumentNullException() { + Func func = () => StringExtensions.WithColor(null, Color.White); + + func.Should().Throw(); + } + } +} diff --git a/tests/TShock.Tests/Utils/ResourceHelperTests.cs b/tests/TShock.Tests/Utils/ResourceHelperTests.cs new file mode 100644 index 000000000..b1858c1fd --- /dev/null +++ b/tests/TShock.Tests/Utils/ResourceHelperTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2019 Pryaxis & TShock Contributors +// +// This file is part of TShock. +// +// TShock is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// TShock is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with TShock. If not, see . + +using System; +using FluentAssertions; +using Xunit; + +namespace TShock.Utils { + public class ResourceHelperTests { + [Fact] + public void LoadResource_PublicProperty() => + ResourceHelper.LoadResource(typeof(TestClass), nameof(TestClass.PublicProperty)) + .Should().Be(TestClass.PublicProperty); + + [Fact] + public void LoadResource_InternalProperty() => + ResourceHelper.LoadResource(typeof(TestClass), nameof(TestClass.InternalProperty)) + .Should().Be(TestClass.InternalProperty); + + [Fact] + public void LoadResource_NullResourceType_ThrowsArgumentNullException() { + Func func = () => ResourceHelper.LoadResource(null, ""); + + func.Should().Throw(); + } + + [Fact] + public void LoadResource_NullName_ThrowsArgumentNullException() { + Func func = () => ResourceHelper.LoadResource(typeof(TestClass), null); + + func.Should().Throw(); + } + + [Fact] + public void LoadResource_InvalidProperty_ThrowsArgumentException() { + Func func = () => ResourceHelper.LoadResource(typeof(TestClass), "DoesNotExist"); + + func.Should().Throw(); + } + + private class TestClass { + public static int PublicProperty => 123; + internal static int InternalProperty => 456; + } + } +}