diff --git a/OpenRA.Game/DescAttribute.cs b/OpenRA.Game/DescAttribute.cs new file mode 100644 index 0000000000..9536de15c3 --- /dev/null +++ b/OpenRA.Game/DescAttribute.cs @@ -0,0 +1,22 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you 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. For more + * information, see COPYING. + */ +#endregion + +using System; + +namespace OpenRA +{ + // Mirrors DescriptionAttribute from System.ComponentModel but we don't want to have to use that everywhere. + [AttributeUsage(AttributeTargets.All)] + public sealed class DescAttribute(params string[] lines) : Attribute + { + public readonly string[] Lines = lines; + } +} diff --git a/OpenRA.Game/Exts.cs b/OpenRA.Game/Exts.cs index ed7e781d81..b8bcb8528a 100644 --- a/OpenRA.Game/Exts.cs +++ b/OpenRA.Game/Exts.cs @@ -548,7 +548,7 @@ namespace OpenRA public static bool TryParseFloatOrPercentInvariant(string s, out float f) { - if (float.TryParse(s.Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo, out f)) + if (float.TryParse(s?.Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo, out f)) { f *= s.Contains('%') ? 0.01f : 1f; return true; diff --git a/OpenRA.Game/FieldLoader.cs b/OpenRA.Game/FieldLoader.cs index d142567dca..ab04eff27f 100644 --- a/OpenRA.Game/FieldLoader.cs +++ b/OpenRA.Game/FieldLoader.cs @@ -23,7 +23,7 @@ namespace OpenRA { public static class FieldLoader { - const char SplitComma = ','; + const char Comma = ','; public class MissingFieldsException : YamlException { @@ -47,7 +47,7 @@ namespace OpenRA } public static Func InvalidValueAction = (s, t, f) => - throw new YamlException($"FieldLoader: Cannot parse `{s}` into `{f}.{t}` "); + throw new YamlException($"FieldLoader: Cannot parse `{s}` into `{f}.{t}`"); public static Action UnknownFieldAction = (s, f) => throw new NotImplementedException($"FieldLoader: Missing field `{s}` on `{f.Name}`"); @@ -59,7 +59,7 @@ namespace OpenRA static readonly ConcurrentCache IntegerExpressionCache = new(expression => new IntegerExpression(expression)); - static readonly Dictionary> TypeParsers = + static readonly Dictionary> TypeParsers = new() { { typeof(int), ParseInt }, @@ -83,7 +83,6 @@ namespace OpenRA { typeof(CVec[]), ParseCVecArray }, { typeof(BooleanExpression), ParseBooleanExpression }, { typeof(IntegerExpression), ParseIntegerExpression }, - { typeof(Enum), ParseEnum }, { typeof(bool), ParseBool }, { typeof(int2[]), ParseInt2Array }, { typeof(Size), ParseSize }, @@ -94,7 +93,7 @@ namespace OpenRA { typeof(DateTime), ParseDateTime } }; - static readonly Dictionary> GenericTypeParsers = + static readonly Dictionary> GenericTypeParsers = new() { { typeof(HashSet<>), ParseHashSetOrList }, @@ -108,7 +107,7 @@ namespace OpenRA static readonly object BoxedFalse = false; static readonly object[] BoxedInts = Exts.MakeArray(33, i => (object)i); - static object ParseInt(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseInt(string fieldName, Type fieldType, string value) { if (Exts.TryParseInt32Invariant(value, out var res)) { @@ -120,48 +119,48 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseUShort(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseUShort(string fieldName, Type fieldType, string value) { - if (ushort.TryParse(value, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out var res)) + if (Exts.TryParseUshortInvariant(value, out var res)) return res; return InvalidValueAction(value, fieldType, fieldName); } - static object ParseLong(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseLong(string fieldName, Type fieldType, string value) { - if (long.TryParse(value, NumberStyles.Integer, NumberFormatInfo.InvariantInfo, out var res)) + if (Exts.TryParseInt64Invariant(value, out var res)) return res; return InvalidValueAction(value, fieldType, fieldName); } - static object ParseFloat(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseFloat(string fieldName, Type fieldType, string value) { - if (value != null && float.TryParse(value.Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo, out var res)) - return res * (value.Contains('%') ? 0.01f : 1f); + if (Exts.TryParseFloatOrPercentInvariant(value, out var res)) + return res; return InvalidValueAction(value, fieldType, fieldName); } - static object ParseDecimal(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseDecimal(string fieldName, Type fieldType, string value) { if (value != null && decimal.TryParse(value.Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo, out var res)) return res * (value.Contains('%') ? 0.01m : 1m); return InvalidValueAction(value, fieldType, fieldName); } - static object ParseString(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseString(string fieldName, Type fieldType, string value) { return value; } - static object ParseColor(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseColor(string fieldName, Type fieldType, string value) { - if (value != null && Color.TryParse(value, out var color)) + if (Color.TryParse(value, out var color)) return color; return InvalidValueAction(value, fieldType, fieldName); } - static object ParseHotkey(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseHotkey(string fieldName, Type fieldType, string value) { if (Hotkey.TryParse(value, out var res)) return res; @@ -169,12 +168,12 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseHotkeyReference(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseHotkeyReference(string fieldName, Type fieldType, string value) { return Game.ModData.Hotkeys[value]; } - static object ParseWDist(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseWDist(string fieldName, Type fieldType, string value) { if (WDist.TryParse(value, out var res)) return res; @@ -182,11 +181,11 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseWVec(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseWVec(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length == 3 && WDist.TryParse(parts[0], out var rx) && WDist.TryParse(parts[1], out var ry) @@ -197,11 +196,11 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseWVecArray(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseWVecArray(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length % 3 != 0) return InvalidValueAction(value, fieldType, fieldName); @@ -214,6 +213,8 @@ namespace OpenRA && WDist.TryParse(parts[3 * i + 1], out var ry) && WDist.TryParse(parts[3 * i + 2], out var rz)) vecs[i] = new WVec(rx, ry, rz); + else + return InvalidValueAction(value, fieldType, fieldName); } return vecs; @@ -222,11 +223,11 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseWPos(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseWPos(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length == 3 && WDist.TryParse(parts[0], out var rx) && WDist.TryParse(parts[1], out var ry) @@ -237,18 +238,18 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseWAngle(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseWAngle(string fieldName, Type fieldType, string value) { if (Exts.TryParseInt32Invariant(value, out var res)) return new WAngle(res); return InvalidValueAction(value, fieldType, fieldName); } - static object ParseWRot(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseWRot(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length == 3 && Exts.TryParseInt32Invariant(parts[0], out var rr) && Exts.TryParseInt32Invariant(parts[1], out var rp) @@ -259,27 +260,31 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseCPos(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseCPos(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length == 3) - return new CPos( - Exts.ParseInt32Invariant(parts[0]), - Exts.ParseInt32Invariant(parts[1]), - Exts.ParseByteInvariant(parts[2])); - return new CPos(Exts.ParseInt32Invariant(parts[0]), Exts.ParseInt32Invariant(parts[1])); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 3 + && Exts.TryParseInt32Invariant(parts[0], out var x) + && Exts.TryParseInt32Invariant(parts[1], out var y) + && Exts.TryParseByteInvariant(parts[2], out var layer)) + return new CPos(x, y, layer); + + if (parts.Length == 2 + && Exts.TryParseInt32Invariant(parts[0], out x) + && Exts.TryParseInt32Invariant(parts[1], out y)) + return new CPos(x, y); } return InvalidValueAction(value, fieldType, fieldName); } - static object ParseCPosArray(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseCPosArray(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length % 2 != 0) return InvalidValueAction(value, fieldType, fieldName); @@ -287,9 +292,11 @@ namespace OpenRA var vecs = new CPos[parts.Length / 2]; for (var i = 0; i < vecs.Length; i++) { - if (int.TryParse(parts[2 * i], out var rx) - && int.TryParse(parts[2 * i + 1], out var ry)) + if (Exts.TryParseInt32Invariant(parts[2 * i], out var rx) + && Exts.TryParseInt32Invariant(parts[2 * i + 1], out var ry)) vecs[i] = new CPos(rx, ry); + else + return InvalidValueAction(value, fieldType, fieldName); } return vecs; @@ -298,22 +305,25 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseCVec(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseCVec(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); - return new CVec(Exts.ParseInt32Invariant(parts[0]), Exts.ParseInt32Invariant(parts[1])); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 2 + && Exts.TryParseInt32Invariant(parts[0], out var x) + && Exts.TryParseInt32Invariant(parts[1], out var y)) + return new CVec(x, y); } return InvalidValueAction(value, fieldType, fieldName); } - static object ParseCVecArray(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseCVecArray(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length % 2 != 0) return InvalidValueAction(value, fieldType, fieldName); @@ -321,9 +331,11 @@ namespace OpenRA var vecs = new CVec[parts.Length / 2]; for (var i = 0; i < vecs.Length; i++) { - if (int.TryParse(parts[2 * i], out var rx) - && int.TryParse(parts[2 * i + 1], out var ry)) + if (Exts.TryParseInt32Invariant(parts[2 * i], out var rx) + && Exts.TryParseInt32Invariant(parts[2 * i + 1], out var ry)) vecs[i] = new CVec(rx, ry); + else + return InvalidValueAction(value, fieldType, fieldName); } return vecs; @@ -332,7 +344,7 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseBooleanExpression(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseBooleanExpression(string fieldName, Type fieldType, string value) { if (value != null) { @@ -342,14 +354,14 @@ namespace OpenRA } catch (InvalidDataException e) { - throw new YamlException(e.Message); + throw new YamlException($"FieldLoader: Cannot parse `{value}` into `{fieldName}.{fieldType}`: {e.Message}"); } } return InvalidValueAction(value, fieldType, fieldName); } - static object ParseIntegerExpression(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseIntegerExpression(string fieldName, Type fieldType, string value) { if (value != null) { @@ -359,44 +371,50 @@ namespace OpenRA } catch (InvalidDataException e) { - throw new YamlException(e.Message); + throw new YamlException($"FieldLoader: Cannot parse `{value}` into `{fieldName}.{fieldType}`: {e.Message}"); } } return InvalidValueAction(value, fieldType, fieldName); } - static object ParseEnum(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseEnum(string fieldName, Type fieldType, string value) { - try + // Will allow numeric values that fit the underlying type of the enum, even if they aren't defined enumeration members. + if (Enum.TryParse(fieldType, value, true, out var enumValue)) { - return Enum.Parse(fieldType, value, true); - } - catch (ArgumentException) - { - return InvalidValueAction(value, fieldType, fieldName); + return enumValue; } + + return InvalidValueAction(value, fieldType, fieldName); } - static object ParseBool(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseBool(string fieldName, Type fieldType, string value) { - if (bool.TryParse(value.ToLowerInvariant(), out var result)) + if (bool.TryParse(value, out var result)) return result ? BoxedTrue : BoxedFalse; return InvalidValueAction(value, fieldType, fieldName); } - static object ParseInt2Array(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseInt2Array(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (parts.Length % 2 != 0) return InvalidValueAction(value, fieldType, fieldName); var ints = new int2[parts.Length / 2]; + for (var i = 0; i < ints.Length; i++) - ints[i] = new int2(Exts.ParseInt32Invariant(parts[2 * i]), Exts.ParseInt32Invariant(parts[2 * i + 1])); + { + if (Exts.TryParseInt32Invariant(parts[2 * i], out var x) + && Exts.TryParseInt32Invariant(parts[2 * i + 1], out var y)) + ints[i] = new int2(x, y); + else + return InvalidValueAction(value, fieldType, fieldName); + } return ints; } @@ -404,109 +422,112 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } - static object ParseSize(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseSize(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); - return new Size(Exts.ParseInt32Invariant(parts[0]), Exts.ParseInt32Invariant(parts[1])); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 2 + && Exts.TryParseInt32Invariant(parts[0], out var width) + && Exts.TryParseInt32Invariant(parts[1], out var height)) + return new Size(width, height); } return InvalidValueAction(value, fieldType, fieldName); } - static object ParseInt2(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseInt2(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length != 2) - return InvalidValueAction(value, fieldType, fieldName); - - return new int2(Exts.ParseInt32Invariant(parts[0]), Exts.ParseInt32Invariant(parts[1])); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 2 + && Exts.TryParseInt32Invariant(parts[0], out var x) + && Exts.TryParseInt32Invariant(parts[1], out var y)) + return new int2(x, y); } return InvalidValueAction(value, fieldType, fieldName); } - static object ParseFloat2(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseFloat2(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); - float xx = 0; - float yy = 0; - if (float.TryParse(parts[0].Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo, out var res)) - xx = res * (parts[0].Contains('%') ? 0.01f : 1f); - if (float.TryParse(parts[1].Replace("%", ""), NumberStyles.Float, NumberFormatInfo.InvariantInfo, out res)) - yy = res * (parts[1].Contains('%') ? 0.01f : 1f); - return new float2(xx, yy); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 2 + && Exts.TryParseFloatOrPercentInvariant(parts[0], out var x) + && Exts.TryParseFloatOrPercentInvariant(parts[1], out var y)) + return new float2(x, y); } return InvalidValueAction(value, fieldType, fieldName); } - static object ParseFloat3(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseFloat3(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); - float.TryParse(parts[0], NumberStyles.Float, NumberFormatInfo.InvariantInfo, out var x); - float.TryParse(parts[1], NumberStyles.Float, NumberFormatInfo.InvariantInfo, out var y); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 3 + && Exts.TryParseFloatOrPercentInvariant(parts[0], out var x) + && Exts.TryParseFloatOrPercentInvariant(parts[1], out var y) + && Exts.TryParseFloatOrPercentInvariant(parts[2], out var z)) + return new float3(x, y, z); // z component is optional for compatibility with older float2 definitions - float z = 0; - if (parts.Length > 2) - float.TryParse(parts[2], NumberStyles.Float, NumberFormatInfo.InvariantInfo, out z); - - return new float3(x, y, z); + if (parts.Length == 2 + && Exts.TryParseFloatOrPercentInvariant(parts[0], out x) + && Exts.TryParseFloatOrPercentInvariant(parts[1], out y)) + return new float3(x, y, 0); } return InvalidValueAction(value, fieldType, fieldName); } - static object ParseRectangle(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseRectangle(string fieldName, Type fieldType, string value) { if (value != null) { - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); - return new Rectangle( - Exts.ParseInt32Invariant(parts[0]), - Exts.ParseInt32Invariant(parts[1]), - Exts.ParseInt32Invariant(parts[2]), - Exts.ParseInt32Invariant(parts[3])); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 4 + && Exts.TryParseInt32Invariant(parts[0], out var x) + && Exts.TryParseInt32Invariant(parts[1], out var y) + && Exts.TryParseInt32Invariant(parts[2], out var width) + && Exts.TryParseInt32Invariant(parts[3], out var height)) + return new Rectangle(x, y, width, height); } return InvalidValueAction(value, fieldType, fieldName); } - static object ParseDateTime(string fieldName, Type fieldType, string value, MemberInfo field) + static object ParseDateTime(string fieldName, Type fieldType, string value) { if (DateTime.TryParseExact(value, "yyyy-MM-dd HH-mm-ss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var dt)) return dt; return InvalidValueAction(value, fieldType, fieldName); } - static object ParseHashSetOrList(string fieldName, Type fieldType, string value, MiniYaml yaml, MemberInfo field) + static object ParseHashSetOrList(string fieldName, Type fieldType, string value, MiniYaml yaml) { if (value == null) return Activator.CreateInstance(fieldType); - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var set = Activator.CreateInstance(fieldType, parts.Length); var arguments = fieldType.GetGenericArguments(); var addMethod = fieldType.GetMethod(nameof(List.Add), arguments); var addArgs = new object[1]; for (var i = 0; i < parts.Length; i++) { - addArgs[0] = GetValue(fieldName, arguments[0], parts[i].Trim(), field); + addArgs[0] = GetValue(fieldName, arguments[0], parts[i]); addMethod.Invoke(set, addArgs); } return set; } - static object ParseDictionary(string fieldName, Type fieldType, string value, MiniYaml yaml, MemberInfo field) + static object ParseDictionary(string fieldName, Type fieldType, string value, MiniYaml yaml) { if (yaml == null) return Activator.CreateInstance(fieldType); @@ -517,33 +538,36 @@ namespace OpenRA var addArgs = new object[2]; foreach (var node in yaml.Nodes) { - addArgs[0] = GetValue(fieldName, arguments[0], node.Key, field); - addArgs[1] = GetValue(fieldName, arguments[1], node.Value, field); + addArgs[0] = GetValue(fieldName, arguments[0], node.Key); + addArgs[1] = GetValue(fieldName, arguments[1], node.Value); addMethod.Invoke(dict, addArgs); } return dict; } - static object ParseBitSet(string fieldName, Type fieldType, string value, MiniYaml yaml, MemberInfo field) + static object ParseBitSet(string fieldName, Type fieldType, string value, MiniYaml yaml) { if (value != null) { - var parts = value.Split(SplitComma, StringSplitOptions.RemoveEmptyEntries); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var ctor = fieldType.GetConstructor([typeof(string[])]); - return ctor.Invoke([parts.Select(p => p.Trim()).ToArray()]); + return ctor.Invoke([parts]); + } + else + { + var ctor = fieldType.GetConstructor([typeof(string[])]); + return ctor.Invoke([Array.Empty()]); } - - return InvalidValueAction(value, fieldType, fieldName); } - static object ParseNullable(string fieldName, Type fieldType, string value, MiniYaml yaml, MemberInfo field) + static object ParseNullable(string fieldName, Type fieldType, string value, MiniYaml yaml) { if (string.IsNullOrEmpty(value)) return null; var innerType = fieldType.GetGenericArguments()[0]; - var innerValue = GetValue("Nullable", innerType, value, field); + var innerValue = GetValue("Nullable", innerType, value); return fieldType.GetConstructor([innerType]).Invoke([innerValue]); } @@ -598,7 +622,7 @@ namespace OpenRA if (!md.TryGetValue(yamlName, out var yaml)) return false; - ret = GetValue(field.Name, field.FieldType, yaml, field); + ret = GetValue(field.Name, field.FieldType, yaml); return true; } @@ -609,7 +633,7 @@ namespace OpenRA return t; } - public static void LoadField(object target, string key, string value) + public static void LoadFieldOrProperty(object target, string key, string value) { const BindingFlags Flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance; @@ -618,18 +642,14 @@ namespace OpenRA var field = target.GetType().GetField(key, Flags); if (field != null) { - var sa = field.GetCustomAttributes(false).DefaultIfEmpty(SerializeAttribute.Default).First(); - if (!sa.FromYamlKey) - field.SetValue(target, GetValue(field.Name, field.FieldType, value, field)); + field.SetValue(target, GetValue(field.Name, field.FieldType, value)); return; } var prop = target.GetType().GetProperty(key, Flags); if (prop != null) { - var sa = prop.GetCustomAttributes(false).DefaultIfEmpty(SerializeAttribute.Default).First(); - if (!sa.FromYamlKey) - prop.SetValue(target, GetValue(prop.Name, prop.PropertyType, value, prop), null); + prop.SetValue(target, GetValue(prop.Name, prop.PropertyType, value), null); return; } @@ -641,48 +661,44 @@ namespace OpenRA return (T)GetValue(field, typeof(T), value, null); } - public static object GetValue(string fieldName, Type fieldType, string value) + static object GetValue(string fieldName, Type fieldType, string value) { return GetValue(fieldName, fieldType, value, null); } - public static object GetValue(string fieldName, Type fieldType, string value, MemberInfo field) + static object GetValue(string fieldName, Type fieldType, MiniYaml yaml) { - return GetValue(fieldName, fieldType, value, null, field); + return GetValue(fieldName, fieldType, yaml.Value, yaml); } - public static object GetValue(string fieldName, Type fieldType, MiniYaml yaml, MemberInfo field) - { - return GetValue(fieldName, fieldType, yaml.Value, yaml, field); - } - - static object GetValue(string fieldName, Type fieldType, string value, MiniYaml yaml, MemberInfo field) + static object GetValue(string fieldName, Type fieldType, string value, MiniYaml yaml) { value = value?.Trim(); if (fieldType.IsGenericType) { if (GenericTypeParsers.TryGetValue(fieldType.GetGenericTypeDefinition(), out var parseFuncGeneric)) - return parseFuncGeneric(fieldName, fieldType, value, yaml, field); + return parseFuncGeneric(fieldName, fieldType, value, yaml); } else { if (TypeParsers.TryGetValue(fieldType, out var parseFunc)) - return parseFunc(fieldName, fieldType, value, field); + return parseFunc(fieldName, fieldType, value); if (fieldType.IsArray && fieldType.GetArrayRank() == 1) { if (value == null) return Array.CreateInstance(fieldType.GetElementType(), 0); - var options = field != null && field.HasAttribute() ? - StringSplitOptions.None : StringSplitOptions.RemoveEmptyEntries; - var parts = value.Split(SplitComma, options); + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var ret = Array.CreateInstance(fieldType.GetElementType(), parts.Length); for (var i = 0; i < parts.Length; i++) - ret.SetValue(GetValue(fieldName, fieldType.GetElementType(), parts[i].Trim(), field), i); + ret.SetValue(GetValue(fieldName, fieldType.GetElementType(), parts[i]), i); return ret; } + + if (fieldType.IsEnum) + return ParseEnum(fieldName, fieldType, value); } var conv = TypeDescriptor.GetConverter(fieldType); @@ -706,21 +722,20 @@ namespace OpenRA { public readonly FieldInfo Field; public readonly SerializeAttribute Attribute; - public readonly string YamlName; public readonly Func Loader; + public string YamlName => Field.Name; - internal FieldLoadInfo(FieldInfo field, SerializeAttribute attr, string yamlName, Func loader = null) + public FieldLoadInfo(FieldInfo field, SerializeAttribute attr, Func loader = null) { Field = field; Attribute = attr; - YamlName = yamlName; Loader = loader; } } - public static IEnumerable GetTypeLoadInfo(Type type, bool includePrivateByDefault = false) + public static IEnumerable GetTypeLoadInfo(Type type) { - return TypeLoadInfo[type].Where(fli => includePrivateByDefault || fli.Field.IsPublic || (fli.Attribute.Serialize && !fli.Attribute.IsDefault)); + return TypeLoadInfo[type].Where(fli => fli.Field.IsPublic || (fli.Attribute.Serialize && !fli.Attribute.IsDefault)); } static FieldLoadInfo[] BuildTypeLoadInfo(Type type) @@ -735,13 +750,9 @@ namespace OpenRA if (!sa.Serialize) continue; - var yamlName = string.IsNullOrEmpty(sa.YamlName) ? field.Name : sa.YamlName; - var loader = sa.GetLoader(type); - if (loader == null && sa.FromYamlKey) - loader = yaml => GetValue(yamlName, field.FieldType, yaml, field); - var fli = new FieldLoadInfo(field, sa, yamlName, loader); + var fli = new FieldLoadInfo(field, sa, loader); ret.Add(fli); } @@ -752,31 +763,21 @@ namespace OpenRA public sealed class IgnoreAttribute : SerializeAttribute { public IgnoreAttribute() - : base(false) { } + : base(serialize: false) { } } [AttributeUsage(AttributeTargets.Field)] public sealed class RequireAttribute : SerializeAttribute { public RequireAttribute() - : base(true, true) { } - } - - [AttributeUsage(AttributeTargets.Field)] - public sealed class AllowEmptyEntriesAttribute : SerializeAttribute - { - public AllowEmptyEntriesAttribute() - : base(allowEmptyEntries: true) { } + : base(serialize: true, required: true) { } } [AttributeUsage(AttributeTargets.Field)] public sealed class LoadUsingAttribute : SerializeAttribute { public LoadUsingAttribute(string loader, bool required = false) - { - Loader = loader; - Required = required; - } + : base(serialize: true, required, loader) { } } [AttributeUsage(AttributeTargets.Field)] @@ -787,17 +788,14 @@ namespace OpenRA public bool IsDefault => this == Default; public readonly bool Serialize; - public string YamlName; - public string Loader; - public bool FromYamlKey; - public bool Required; - public bool AllowEmptyEntries; + public readonly bool Required; + public readonly string Loader; - public SerializeAttribute(bool serialize = true, bool required = false, bool allowEmptyEntries = false) + protected SerializeAttribute(bool serialize = true, bool required = false, string loader = null) { Serialize = serialize; Required = required; - AllowEmptyEntries = allowEmptyEntries; + Loader = loader; } internal Func GetLoader(Type type) @@ -817,20 +815,4 @@ namespace OpenRA } } } - - [AttributeUsage(AttributeTargets.Field)] - public sealed class FieldFromYamlKeyAttribute : FieldLoader.SerializeAttribute - { - public FieldFromYamlKeyAttribute() - { - FromYamlKey = true; - } - } - - // Mirrors DescriptionAttribute from System.ComponentModel but we don't want to have to use that everywhere. - [AttributeUsage(AttributeTargets.All)] - public sealed class DescAttribute(params string[] lines) : Attribute - { - public readonly string[] Lines = lines; - } } diff --git a/OpenRA.Game/FieldSaver.cs b/OpenRA.Game/FieldSaver.cs index d3ffa79604..f42d6cbc9b 100644 --- a/OpenRA.Game/FieldSaver.cs +++ b/OpenRA.Game/FieldSaver.cs @@ -22,12 +22,11 @@ namespace OpenRA { public static class FieldSaver { - public static MiniYaml Save(object o, bool includePrivateByDefault = false) + public static MiniYaml Save(object o) { var nodes = new List(); - string root = null; - foreach (var fieldInfo in FieldLoader.GetTypeLoadInfo(o.GetType(), includePrivateByDefault)) + foreach (var fieldInfo in FieldLoader.GetTypeLoadInfo(o.GetType())) { if (fieldInfo.Field.FieldType.IsGenericType && fieldInfo.Field.FieldType.IsAssignableTo(typeof(System.Collections.IDictionary))) { @@ -42,21 +41,19 @@ namespace OpenRA nodes.Add(new MiniYamlNode(fieldInfo.YamlName, "", dictNodes)); } - else if (fieldInfo.Attribute.FromYamlKey) - root = FormatValue(o, fieldInfo.Field); else nodes.Add(new MiniYamlNode(fieldInfo.YamlName, FormatValue(o, fieldInfo.Field))); } - return new MiniYaml(root, nodes); + return new MiniYaml(null, nodes); } - public static MiniYaml SaveDifferences(object o, object from, bool includePrivateByDefault = false) + public static MiniYaml SaveDifferences(object o, object from) { if (o.GetType() != from.GetType()) - throw new InvalidOperationException("FieldLoader: can't diff objects of different types"); + throw new InvalidOperationException("FieldSaver: can't diff objects of different types"); - var fields = FieldLoader.GetTypeLoadInfo(o.GetType(), includePrivateByDefault) + var fields = FieldLoader.GetTypeLoadInfo(o.GetType()) .Where(info => FormatValue(o, info.Field) != FormatValue(from, info.Field)); return new MiniYaml( diff --git a/OpenRA.Game/Map/Map.cs b/OpenRA.Game/Map/Map.cs index d5ab941116..f19f38fb35 100644 --- a/OpenRA.Game/Map/Map.cs +++ b/OpenRA.Game/Map/Map.cs @@ -111,7 +111,7 @@ namespace OpenRA else if (type == Type.MiniYaml) field.SetValue(map, node.Value); else - FieldLoader.LoadField(map, fieldName, node.Value.Value); + FieldLoader.LoadFieldOrProperty(map, fieldName, node.Value.Value); } if (property != null) @@ -121,7 +121,7 @@ namespace OpenRA else if (type == Type.MiniYaml) property.SetValue(map, node.Value, null); else - FieldLoader.LoadField(map, fieldName, node.Value.Value); + FieldLoader.LoadFieldOrProperty(map, fieldName, node.Value.Value); } } diff --git a/OpenRA.Game/Primitives/Color.cs b/OpenRA.Game/Primitives/Color.cs index e50c107f96..5b0d3d5935 100644 --- a/OpenRA.Game/Primitives/Color.cs +++ b/OpenRA.Game/Primitives/Color.cs @@ -158,6 +158,9 @@ namespace OpenRA.Primitives public static bool TryParse(string value, out Color color) { color = default; + if (value == null) + return false; + value = value.Trim(); if (value.Length != 6 && value.Length != 8) return false; diff --git a/OpenRA.Game/Settings.cs b/OpenRA.Game/Settings.cs index 0e2f937def..33edca79ea 100644 --- a/OpenRA.Game/Settings.cs +++ b/OpenRA.Game/Settings.cs @@ -369,7 +369,7 @@ namespace OpenRA foreach (var kv in Sections) foreach (var f in kv.Value.GetType().GetFields()) if (args.Contains(kv.Key + "." + f.Name)) - FieldLoader.LoadField(kv.Value, f.Name, args.GetValue(kv.Key + "." + f.Name, "")); + FieldLoader.LoadFieldOrProperty(kv.Value, f.Name, args.GetValue(kv.Key + "." + f.Name, "")); } finally { diff --git a/OpenRA.Game/Support/LaunchArguments.cs b/OpenRA.Game/Support/LaunchArguments.cs index a529a9818c..d233667c3b 100644 --- a/OpenRA.Game/Support/LaunchArguments.cs +++ b/OpenRA.Game/Support/LaunchArguments.cs @@ -38,7 +38,7 @@ namespace OpenRA foreach (var f in GetType().GetFields()) if (args.Contains("Launch." + f.Name)) - FieldLoader.LoadField(this, f.Name, args.GetValue("Launch." + f.Name, "")); + FieldLoader.LoadFieldOrProperty(this, f.Name, args.GetValue("Launch." + f.Name, "")); } public ConnectionTarget GetConnectEndPoint() diff --git a/OpenRA.Game/Support/VariableExpression.cs b/OpenRA.Game/Support/VariableExpression.cs index a4df3b052d..bbb3240915 100644 --- a/OpenRA.Game/Support/VariableExpression.cs +++ b/OpenRA.Game/Support/VariableExpression.cs @@ -581,6 +581,8 @@ namespace OpenRA.Support Expression = expression; } + public override string ToString() => Expression; + Expression Build(ExpressionType resultType) { var tokens = new List(); diff --git a/OpenRA.Game/Widgets/WidgetLoader.cs b/OpenRA.Game/Widgets/WidgetLoader.cs index 1866501b5e..0f90d17638 100644 --- a/OpenRA.Game/Widgets/WidgetLoader.cs +++ b/OpenRA.Game/Widgets/WidgetLoader.cs @@ -55,11 +55,11 @@ namespace OpenRA parent?.AddChild(widget); if (node.Key.Contains('@')) - FieldLoader.LoadField(widget, "Id", node.Key.Split('@')[1]); + FieldLoader.LoadFieldOrProperty(widget, "Id", node.Key.Split('@')[1]); foreach (var child in node.Value.Nodes) if (child.Key != "Children") - FieldLoader.LoadField(widget, child.Key, child.Value.Value); + FieldLoader.LoadFieldOrProperty(widget, child.Key, child.Value.Value); widget.Initialize(args); diff --git a/OpenRA.Mods.Common/ServerTraits/SkirmishLogic.cs b/OpenRA.Mods.Common/ServerTraits/SkirmishLogic.cs index 37699c2cf8..7a64b49606 100644 --- a/OpenRA.Mods.Common/ServerTraits/SkirmishLogic.cs +++ b/OpenRA.Mods.Common/ServerTraits/SkirmishLogic.cs @@ -24,7 +24,9 @@ namespace OpenRA.Mods.Common.Server { sealed class SkirmishSlot { - [FieldLoader.Serialize(FromYamlKey = true)] + static string LoadSlot(MiniYaml yaml) => yaml.Value; + + [FieldLoader.LoadUsing(nameof(LoadSlot))] public readonly string Slot; public readonly Color Color; public readonly string Faction; @@ -54,6 +56,12 @@ namespace OpenRA.Mods.Common.Server c.Team = s.Team; c.Handicap = s.Handicap; } + + public MiniYaml ToYaml() + { + var yaml = FieldSaver.Save(this); + return yaml.WithValue(Slot).WithNodes(yaml.Nodes.RemoveAll(n => n.Key == nameof(Slot))); + } } static bool TryInitializeFromFile(S server, string path, Connection conn) @@ -156,9 +164,9 @@ namespace OpenRA.Mods.Common.Server new("Map", server.LobbyInfo.GlobalSettings.Map), new("Options", new MiniYaml("", server.LobbyInfo.GlobalSettings.LobbyOptions .Select(kv => new MiniYamlNode(kv.Key, kv.Value.Value)))), - new("Player", FieldSaver.Save(new SkirmishSlot(playerClient))), + new("Player", new SkirmishSlot(playerClient).ToYaml()), new("Bots", new MiniYaml("", server.LobbyInfo.Clients.Where(c => c.IsBot) - .Select(b => new MiniYamlNode(b.Bot, FieldSaver.Save(new SkirmishSlot(b)))))) + .Select(b => new MiniYamlNode(b.Bot, new SkirmishSlot(b).ToYaml())))) }.WriteToFile(path); } diff --git a/OpenRA.Test/OpenRA.Game/FieldLoaderTest.cs b/OpenRA.Test/OpenRA.Game/FieldLoaderTest.cs new file mode 100644 index 0000000000..e0e5c5d0dc --- /dev/null +++ b/OpenRA.Test/OpenRA.Game/FieldLoaderTest.cs @@ -0,0 +1,719 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you 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. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using NUnit.Framework.Internal; +using OpenRA.Primitives; +using OpenRA.Support; + +namespace OpenRA.Test +{ + [TestFixture] + sealed class FieldLoaderTest + { + sealed class TypeInfo + { +#pragma warning disable CS0169 +#pragma warning disable CS0649 +#pragma warning disable CA1823 // Avoid unused private fields +#pragma warning disable IDE0044 // Add readonly modifier +#pragma warning disable IDE0051 // Remove unused private members +#pragma warning disable RCS1170 // Use read-only auto-implemented property + int privateField; + public int PublicField; + + [FieldLoader.Ignore] + int privateIgnoreField; + [FieldLoader.Ignore] + public int PublicIgnoreField; + + [FieldLoader.Require] + int privateRequiredField; + [FieldLoader.Require] + public int PublicRequiredField; + + [FieldLoader.LoadUsing(nameof(LoadInt32))] + int privateLoadUsingField; + [FieldLoader.LoadUsing(nameof(LoadInt32))] + public int PublicLoadUsingField; + + int PrivateProperty { get; set; } + public int PublicProperty { get; set; } + + static int privateStaticField; + public static int PublicStaticField; + + static object LoadInt32(MiniYaml _) => 123; +#pragma warning restore RCS1170 // Use read-only auto-implemented property +#pragma warning restore IDE0051 // Remove unused private members +#pragma warning restore IDE0044 // Add readonly modifier +#pragma warning restore CA1823 // Avoid unused private fields +#pragma warning disable CS0649 +#pragma warning restore CS0169 + } + + [Test] + public void GetTypeLoadInfo() + { + var infos = FieldLoader.GetTypeLoadInfo(typeof(TypeInfo)).ToList(); + + Assert.That(infos, Has.Count.EqualTo(5)); + + Assert.That(infos[0].Field, Is.EqualTo(typeof(TypeInfo).GetField(nameof(TypeInfo.PublicField)))); + Assert.That(infos[0].Attribute, Is.EqualTo(FieldLoader.SerializeAttribute.Default)); + Assert.That(infos[0].YamlName, Is.EqualTo(nameof(TypeInfo.PublicField))); + Assert.That(infos[0].Loader, Is.Null); + + Assert.That(infos[1].Field, Is.EqualTo(typeof(TypeInfo).GetField("privateRequiredField", BindingFlags.NonPublic | BindingFlags.Instance))); + Assert.That(infos[1].Attribute, Is.EqualTo(new FieldLoader.RequireAttribute())); + Assert.That(infos[1].YamlName, Is.EqualTo("privateRequiredField")); + Assert.That(infos[1].Loader, Is.Null); + + Assert.That(infos[2].Field, Is.EqualTo(typeof(TypeInfo).GetField(nameof(TypeInfo.PublicRequiredField)))); + Assert.That(infos[2].Attribute, Is.EqualTo(new FieldLoader.RequireAttribute())); + Assert.That(infos[2].YamlName, Is.EqualTo(nameof(TypeInfo.PublicRequiredField))); + Assert.That(infos[2].Loader, Is.Null); + + Assert.That(infos[3].Field, Is.EqualTo(typeof(TypeInfo).GetField("privateLoadUsingField", BindingFlags.NonPublic | BindingFlags.Instance))); + Assert.That(infos[3].Attribute, Is.EqualTo(new FieldLoader.LoadUsingAttribute("LoadInt32"))); + Assert.That(infos[3].YamlName, Is.EqualTo("privateLoadUsingField")); + Assert.That(infos[3].Loader(new MiniYaml(null)), Is.EqualTo(123)); + + Assert.That(infos[4].Field, Is.EqualTo(typeof(TypeInfo).GetField(nameof(TypeInfo.PublicLoadUsingField)))); + Assert.That(infos[4].Attribute, Is.EqualTo(new FieldLoader.LoadUsingAttribute("LoadInt32"))); + Assert.That(infos[4].YamlName, Is.EqualTo(nameof(TypeInfo.PublicLoadUsingField))); + Assert.That(infos[4].Loader(new MiniYaml(null)), Is.EqualTo(123)); + } + + [Test] + public void GetValue_UnknownField() + { + static void Act() => FieldLoader.GetValue("field", "test"); + + Assert.That(Act, Throws.TypeOf().And.Message.EqualTo("FieldLoader: Missing field `[Type] test` on `Object`")); + } + + static IEnumerable GetValue_InvalidValue_TestCases() + { + return + [ + new TestCaseData(null) { TypeArgs = [typeof(int)] }, + new TestCaseData("test") { TypeArgs = [typeof(int)] }, + new TestCaseData("1.2") { TypeArgs = [typeof(int)] }, + new TestCaseData((int.MaxValue + 1L).ToString(CultureInfo.InvariantCulture)) { TypeArgs = [typeof(int)] }, + new TestCaseData(null) { TypeArgs = [typeof(ushort)] }, + new TestCaseData("test") { TypeArgs = [typeof(ushort)] }, + new TestCaseData("1.2") { TypeArgs = [typeof(ushort)] }, + new TestCaseData((ushort.MaxValue + 1L).ToString(CultureInfo.InvariantCulture)) { TypeArgs = [typeof(ushort)] }, + new TestCaseData(null) { TypeArgs = [typeof(long)] }, + new TestCaseData("test") { TypeArgs = [typeof(long)] }, + new TestCaseData("1.2") { TypeArgs = [typeof(long)] }, + new TestCaseData((long.MaxValue + 1UL).ToString(CultureInfo.InvariantCulture)) { TypeArgs = [typeof(long)] }, + new TestCaseData(null) { TypeArgs = [typeof(float)] }, + new TestCaseData("test") { TypeArgs = [typeof(float)] }, + new TestCaseData("1,2") { TypeArgs = [typeof(float)] }, + new TestCaseData(null) { TypeArgs = [typeof(decimal)] }, + new TestCaseData("test") { TypeArgs = [typeof(decimal)] }, + new TestCaseData("1,2") { TypeArgs = [typeof(decimal)] }, + new TestCaseData(null) { TypeArgs = [typeof(Color)] }, + new TestCaseData("test") { TypeArgs = [typeof(Color)] }, + new TestCaseData(null) { TypeArgs = [typeof(Hotkey)] }, + new TestCaseData("test") { TypeArgs = [typeof(Hotkey)] }, + new TestCaseData(null) { TypeArgs = [typeof(WDist)] }, + new TestCaseData("test") { TypeArgs = [typeof(WDist)] }, + new TestCaseData(null) { TypeArgs = [typeof(WVec)] }, + new TestCaseData("test") { TypeArgs = [typeof(WVec)] }, + new TestCaseData("1,2") { TypeArgs = [typeof(WVec)] }, + new TestCaseData("1,test,3") { TypeArgs = [typeof(WVec)] }, + new TestCaseData("1,2,3,4") { TypeArgs = [typeof(WVec)] }, + new TestCaseData(null) { TypeArgs = [typeof(WVec[])] }, + new TestCaseData("test") { TypeArgs = [typeof(WVec[])] }, + new TestCaseData("1,2") { TypeArgs = [typeof(WVec[])] }, + new TestCaseData("1,test,3") { TypeArgs = [typeof(WVec[])] }, + new TestCaseData("1,2,3,4") { TypeArgs = [typeof(WVec[])] }, + new TestCaseData(null) { TypeArgs = [typeof(WPos)] }, + new TestCaseData("test") { TypeArgs = [typeof(WPos)] }, + new TestCaseData("1,2") { TypeArgs = [typeof(WPos)] }, + new TestCaseData("1,test,3") { TypeArgs = [typeof(WPos)] }, + new TestCaseData("1,2,3,4") { TypeArgs = [typeof(WPos)] }, + new TestCaseData(null) { TypeArgs = [typeof(WAngle)] }, + new TestCaseData("test") { TypeArgs = [typeof(WAngle)] }, + new TestCaseData("1,2") { TypeArgs = [typeof(WAngle)] }, + new TestCaseData(null) { TypeArgs = [typeof(WRot)] }, + new TestCaseData("test") { TypeArgs = [typeof(WRot)] }, + new TestCaseData("1,2") { TypeArgs = [typeof(WRot)] }, + new TestCaseData("1,test,3") { TypeArgs = [typeof(WRot)] }, + new TestCaseData("1,2,3,4") { TypeArgs = [typeof(WRot)] }, + new TestCaseData(null) { TypeArgs = [typeof(CPos)] }, + new TestCaseData("test") { TypeArgs = [typeof(CPos)] }, + new TestCaseData("1") { TypeArgs = [typeof(CPos)] }, + new TestCaseData("1,test,3") { TypeArgs = [typeof(CPos)] }, + new TestCaseData("1,2,3,4") { TypeArgs = [typeof(CPos)] }, + new TestCaseData(null) { TypeArgs = [typeof(CPos[])] }, + new TestCaseData("test") { TypeArgs = [typeof(CPos[])] }, + new TestCaseData("1") { TypeArgs = [typeof(CPos[])] }, + new TestCaseData("1,test") { TypeArgs = [typeof(CPos[])] }, + new TestCaseData("1,2,3") { TypeArgs = [typeof(CPos[])] }, + new TestCaseData(null) { TypeArgs = [typeof(CVec)] }, + new TestCaseData("test") { TypeArgs = [typeof(CVec)] }, + new TestCaseData("1") { TypeArgs = [typeof(CVec)] }, + new TestCaseData("1,test") { TypeArgs = [typeof(CVec)] }, + new TestCaseData("1,2,3") { TypeArgs = [typeof(CVec)] }, + new TestCaseData(null) { TypeArgs = [typeof(CVec[])] }, + new TestCaseData("test") { TypeArgs = [typeof(CVec[])] }, + new TestCaseData("1") { TypeArgs = [typeof(CVec[])] }, + new TestCaseData("1,test") { TypeArgs = [typeof(CVec[])] }, + new TestCaseData("1,2,3") { TypeArgs = [typeof(CVec[])] }, + new TestCaseData(null) { TypeArgs = [typeof(BooleanExpression)] }, + new TestCaseData(null) { TypeArgs = [typeof(IntegerExpression)] }, + new TestCaseData(null) { TypeArgs = [typeof(MapGridType)] }, + new TestCaseData(null) { TypeArgs = [typeof(bool)] }, + new TestCaseData("test") { TypeArgs = [typeof(bool)] }, + new TestCaseData(null) { TypeArgs = [typeof(int2[])] }, + new TestCaseData("test") { TypeArgs = [typeof(int2[])] }, + new TestCaseData("1") { TypeArgs = [typeof(int2[])] }, + new TestCaseData("1,test") { TypeArgs = [typeof(int2[])] }, + new TestCaseData("1,2,3") { TypeArgs = [typeof(int2[])] }, + new TestCaseData(null) { TypeArgs = [typeof(Size)] }, + new TestCaseData("test") { TypeArgs = [typeof(Size)] }, + new TestCaseData("1") { TypeArgs = [typeof(Size)] }, + new TestCaseData("1,test") { TypeArgs = [typeof(Size)] }, + new TestCaseData("1,2,3") { TypeArgs = [typeof(Size)] }, + new TestCaseData(null) { TypeArgs = [typeof(int2)] }, + new TestCaseData("test") { TypeArgs = [typeof(int2)] }, + new TestCaseData("1") { TypeArgs = [typeof(int2)] }, + new TestCaseData("1,test") { TypeArgs = [typeof(int2)] }, + new TestCaseData("1,2,3") { TypeArgs = [typeof(int2)] }, + new TestCaseData(null) { TypeArgs = [typeof(float2)] }, + new TestCaseData("test") { TypeArgs = [typeof(float2)] }, + new TestCaseData("1") { TypeArgs = [typeof(float2)] }, + new TestCaseData("1,test") { TypeArgs = [typeof(float2)] }, + new TestCaseData("1,2,3") { TypeArgs = [typeof(float2)] }, + new TestCaseData(null) { TypeArgs = [typeof(float3)] }, + new TestCaseData("test") { TypeArgs = [typeof(float3)] }, + new TestCaseData("1") { TypeArgs = [typeof(float3)] }, + new TestCaseData("1,test") { TypeArgs = [typeof(float3)] }, + new TestCaseData("1,2,3,4") { TypeArgs = [typeof(float3)] }, + new TestCaseData(null) { TypeArgs = [typeof(Rectangle)] }, + new TestCaseData("test") { TypeArgs = [typeof(Rectangle)] }, + new TestCaseData("1,2,3") { TypeArgs = [typeof(Rectangle)] }, + new TestCaseData("1,test,3,4") { TypeArgs = [typeof(Rectangle)] }, + new TestCaseData("1,2,3,4,5") { TypeArgs = [typeof(Rectangle)] }, + new TestCaseData(null) { TypeArgs = [typeof(DateTime)] }, + new TestCaseData("test") { TypeArgs = [typeof(DateTime)] }, + new TestCaseData("2000-01-01") { TypeArgs = [typeof(DateTime)] }, + ]; + } + + [TestCaseSource(nameof(GetValue_InvalidValue_TestCases))] + public void GetValue_InvalidValue(string input) + { + void Act() => FieldLoader.GetValue("field", input); + + Assert.That(Act, Throws.TypeOf().And.Message.EqualTo($"FieldLoader: Cannot parse `{input}` into `field.{typeof(T).FullName}`")); + } + + [TestCase(TypeArgs = [typeof(BooleanExpression)])] + [TestCase(TypeArgs = [typeof(IntegerExpression)])] + public void GetValue_InvalidValue() + { + static void Act() => FieldLoader.GetValue("field", ""); + + Assert.That(Act, Throws.TypeOf().And.Message.EqualTo($"FieldLoader: Cannot parse `` into `field.{typeof(T).FullName}`: Empty expression")); + } + + static IEnumerable GetValue_Primitive_TestCases() + { + return + [ + new TestCaseData(123), + new TestCaseData((ushort)123), + new TestCaseData(123L), + new TestCaseData(123.4f), + new TestCaseData(123m), + new TestCaseData("test"), + new TestCaseData(Color.CornflowerBlue), + new TestCaseData(new Hotkey(Keycode.A, Modifiers.Shift)), + new TestCaseData(new WDist(123)), + new TestCaseData(new WVec(123, 456, 789)), + new TestCaseData(new WPos(123, 456, 789)), + new TestCaseData(new WAngle(123)), + new TestCaseData(new WRot(new WAngle(123), new WAngle(456), new WAngle(789))), + new TestCaseData(new CPos(123, 456)), + new TestCaseData(new CPos(123, 456, 78)), + new TestCaseData(new CVec(123, 456)), + new TestCaseData(MapGridType.RectangularIsometric), + new TestCaseData((MapGridType)byte.MaxValue), + new TestCaseData(SystemActors.World | SystemActors.EditorWorld), + new TestCaseData(true), + new TestCaseData(new Size(123, 456)), + new TestCaseData(new int2(123, 456)), + new TestCaseData(new float2(123, 456)), + new TestCaseData(new float3(123, 456, 789)), + new TestCaseData(new Rectangle(123, 456, 789, 123)), + ]; + } + + [TestCaseSource(nameof(GetValue_Primitive_TestCases))] + public void GetValue_Primitive(T expected) + { + var actual = FieldLoader.GetValue("field", $" {expected} "); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void GetValue_BooleanExpression() + { + var actual = FieldLoader.GetValue("field", " true "); + + Assert.That(actual.Expression, Is.EqualTo("true")); + } + + [Test] + public void GetValue_IntegerExpression() + { + var actual = FieldLoader.GetValue("field", " 1 + 2 "); + + Assert.That(actual.Expression, Is.EqualTo("1 + 2")); + } + + [Test] + public void GetValue_DateTime() + { + var expected = new DateTime(2000, 1, 1); + var input = expected.ToString("yyyy-MM-dd HH-mm-ss", CultureInfo.InvariantCulture); + + var actual = FieldLoader.GetValue("field", $" {input} "); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase("123%", 1.23f)] + [TestCase("%123", 1.23f)] + [TestCase("123.456%", 1.23456f)] + [TestCase("%123.456", 1.23456f)] + public void GetValue_FloatPercentage(string input, float expected) + { + var actual = FieldLoader.GetValue("field", $" {input} "); + + Assert.That(actual, Is.EqualTo(expected)); + } + + static IEnumerable GetValue_DecimalPercentage_TestCases() + { + return + [ + new TestCaseData("123%", 1.23m), + new TestCaseData("%123", 1.23m), + new TestCaseData("123.456%", 1.23456m), + new TestCaseData("%123.456", 1.23456m), + ]; + } + + [TestCaseSource(nameof(GetValue_DecimalPercentage_TestCases))] + public void GetValue_DecimalPercentage(string input, decimal expected) + { + var actual = FieldLoader.GetValue("field", $" {input} "); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void GetValue_float3_TwoElements() + { + var actual = FieldLoader.GetValue("field", "123,456"); + + Assert.That(actual, Is.EqualTo(new float3(123, 456, 0))); + } + + [Test] + public void GetValue_WVecArray() + { + var actual = FieldLoader.GetValue("field", " 1 , 2 , 3 , 4 , 5 , 6 , , "); + + Assert.That(actual, Is.EqualTo(new WVec[] { new(1, 2, 3), new(4, 5, 6) })); + } + + [Test] + public void GetValue_CPosArray() + { + var actual = FieldLoader.GetValue("field", " 1 , 2 , 3 , 4 , 5 , 6 , , "); + + Assert.That(actual, Is.EqualTo(new CPos[] { new(1, 2), new(3, 4), new(5, 6) })); + } + + [Test] + public void GetValue_CVecArray() + { + var actual = FieldLoader.GetValue("field", " 1 , 2 , 3 , 4 , 5 , 6 , , "); + + Assert.That(actual, Is.EqualTo(new CVec[] { new(1, 2), new(3, 4), new(5, 6) })); + } + + [Test] + public void GetValue_int2Array() + { + var actual = FieldLoader.GetValue("field", " 1 , 2 , 3 , 4 , 5 , 6 , , "); + + Assert.That(actual, Is.EqualTo(new int2[] { new(1, 2), new(3, 4), new(5, 6) })); + } + + [TestCase(null, null)] + [TestCase("", null)] + [TestCase("123", 123)] + public void GetValue_Nullable(string input, int? expected) + { + var actual = FieldLoader.GetValue("field", $" {input} "); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(null, new int[] { })] + [TestCase("", new int[] { })] + [TestCase("1", new int[] { 1 })] + [TestCase("1,2,3", new int[] { 1, 2, 3 })] + [TestCase("1,,3", new int[] { 1, 3 })] + [TestCase(" 1 , 2 , 3 ", new int[] { 1, 2, 3 })] + [TestCase(" 1 , , 3 ", new int[] { 1, 3 })] + [TestCase("1,1,2,2,3", new int[] { 1, 1, 2, 2, 3 })] + [TestCase("1,1,1,1", new int[] { 1, 1, 1, 1 })] + public void GetValue_Array(string input, int[] expected) + { + var actual = FieldLoader.GetValue("field", input); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(null, new int[] { })] + [TestCase("", new int[] { })] + [TestCase("1", new int[] { 1 })] + [TestCase("1,2,3", new int[] { 1, 2, 3 })] + [TestCase("1,,3", new int[] { 1, 3 })] + [TestCase(" 1 , 2 , 3 ", new int[] { 1, 2, 3 })] + [TestCase(" 1 , , 3 ", new int[] { 1, 3 })] + [TestCase("1,1,2,2,3", new int[] { 1, 1, 2, 2, 3 })] + [TestCase("1,1,1,1", new int[] { 1, 1, 1, 1 })] + public void GetValue_List(string input, int[] expected) + { + var actual = FieldLoader.GetValue>("field", input); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(null, new int[] { })] + [TestCase("", new int[] { })] + [TestCase("1", new int[] { 1 })] + [TestCase("1,2,3", new int[] { 1, 2, 3 })] + [TestCase("1,,3", new int[] { 1, 3 })] + [TestCase(" 1 , 2 , 3 ", new int[] { 1, 2, 3 })] + [TestCase(" 1 , , 3 ", new int[] { 1, 3 })] + [TestCase("1,1,2,2,3", new int[] { 1, 2, 3 })] + [TestCase("1,1,1,1", new int[] { 1 })] + public void GetValue_HashSet(string input, int[] expected) + { + var actual = FieldLoader.GetValue>("field", input); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("1")] + [TestCase("1,2,3")] + [TestCase("1,,3")] + [TestCase(" 1 , 2 , 3 ")] + [TestCase(" 1 , , 3 ")] + [TestCase("1,1,2,2,3")] + [TestCase("1,1,1,1")] + public void GetValue_BitSet(string input) + { + var actual = FieldLoader.GetValue>("field", input); + var expected = input == null + ? new BitSet([]) + : new BitSet(input.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("1")] + [TestCase("1,2")] + public void GetValue_Dictionary(string input) + { + var actual = FieldLoader.GetValue>("field", input); + + Assert.That(actual, Is.Empty); + } + + [TestCase(TypeArgs = [typeof(int[][])])] + [TestCase(TypeArgs = [typeof(List>)])] + [TestCase(TypeArgs = [typeof(HashSet>)])] + public void GetValue_NestedCollections() + { + var actual = FieldLoader.GetValue("field", "1,2,3"); + + Assert.That(actual, Is.EquivalentTo(new int[][] { [1], [2], [3] })); + } + + [Test] + public void GetValue_TypeConverter() + { + // We don't have hardcoded handling for sbyte, but a TypeConverter for it does exist. + var actual = FieldLoader.GetValue("field", " 123 "); + + Assert.That(actual, Is.EqualTo(123)); + } + + [Test] + public void GetValue_TypeConverter_Invalid() + { + static void Act() => FieldLoader.GetValue("field", " test "); + + Assert.That(Act, Throws.TypeOf().And.Message.EqualTo($"FieldLoader: Cannot parse `test` into `field.{typeof(sbyte).FullName}`")); + } + + sealed class LoadFieldOrPropertyTarget + { +#pragma warning disable IDE0044 // Add readonly modifier +#pragma warning disable RCS1170 // Use read-only auto-implemented property + int privateIntField; + int PrivateIntProp { get; set; } + + public int PublicIntField; + public int PublicIntProp { get; set; } + + public int GetPrivateIntField() => privateIntField; + public int GetPrivateIntProp() => PrivateIntProp; +#pragma warning restore IDE0044 // Add readonly modifier +#pragma warning restore RCS1170 // Use read-only auto-implemented property + } + + [Test] + public void LoadFieldOrProperty() + { + var target = new LoadFieldOrPropertyTarget(); + FieldLoader.LoadFieldOrProperty(target, $" {nameof(LoadFieldOrPropertyTarget.PublicIntField)} ", "12"); + FieldLoader.LoadFieldOrProperty(target, $" {nameof(LoadFieldOrPropertyTarget.PublicIntProp)} ", "34"); + FieldLoader.LoadFieldOrProperty(target, " privateIntField ", "56"); + FieldLoader.LoadFieldOrProperty(target, " PrivateIntProp ", "78"); + void Act() => FieldLoader.LoadFieldOrProperty(target, "unknown", ""); + + Assert.That(target.PublicIntField, Is.EqualTo(12)); + Assert.That(target.PublicIntProp, Is.EqualTo(34)); + Assert.That(target.GetPrivateIntField(), Is.EqualTo(56)); + Assert.That(target.GetPrivateIntProp(), Is.EqualTo(78)); + Assert.That(Act, Throws.TypeOf().And.Message.EqualTo("FieldLoader: Missing field `unknown` on `LoadFieldOrPropertyTarget`")); + } + + sealed class LoadTarget + { + public int Int; + public string String; + public string Unset; + } + + [Test] + public void Load() + { + var target = new LoadTarget() { Unset = "unset" }; + var yaml = new MiniYaml( + null, + [ + new MiniYamlNode(nameof(LoadTarget.Int), "123"), + new MiniYamlNode(nameof(LoadTarget.String), "test"), + ]); + + FieldLoader.Load(target, yaml); + + Assert.That(target.Int, Is.EqualTo(123)); + Assert.That(target.String, Is.EqualTo("test")); + Assert.That(target.Unset, Is.EqualTo("unset")); + } + + [Test] + public void Load_Generic() + { + var expected = new LoadTarget(); + var yaml = new MiniYaml( + null, + [ + new MiniYamlNode(nameof(LoadTarget.Int), "123"), + new MiniYamlNode(nameof(LoadTarget.String), "test"), + ]); + FieldLoader.Load(expected, yaml); + + var actual = FieldLoader.Load(yaml); + + Assert.That(actual.Int, Is.EqualTo(expected.Int)); + Assert.That(actual.String, Is.EqualTo(expected.String)); + Assert.That(actual.Unset, Is.EqualTo(expected.Unset)); + } + + sealed class LoadDictionaryTarget + { + public Dictionary Dictionary; + } + + [Test] + public void Load_Dictionary() + { + var target = new LoadDictionaryTarget(); + var yaml = new MiniYaml( + null, + [ + new MiniYamlNode( + nameof(LoadDictionaryTarget.Dictionary), + new MiniYaml( + null, + [ + new MiniYamlNode("12", "34"), + new MiniYamlNode("56", "78") + ])) + ]); + + FieldLoader.Load(target, yaml); + + Assert.That(target.Dictionary, Is.EquivalentTo(new Dictionary { { 12, 34 }, { 56, 78 } })); + } + + sealed class LoadRequiredTarget + { + [FieldLoader.Require] + public int Int1 = 1; + [FieldLoader.Require] + public int Int2 = 2; + [FieldLoader.Require] + public int Int3 = 3; + + public int Int4 = 4; + public int Int5 = 5; + } + + [Test] + public void Load_Required() + { + var target = new LoadRequiredTarget(); + var yaml = new MiniYaml( + null, + [ + new MiniYamlNode(nameof(LoadRequiredTarget.Int1), "123"), + new MiniYamlNode(nameof(LoadRequiredTarget.Int4), "456"), + ]); + + void Act() => FieldLoader.Load(target, yaml); + + Assert.That(Act, + Throws.TypeOf().And + .Message.EqualTo($"{nameof(LoadRequiredTarget.Int2)}, {nameof(LoadRequiredTarget.Int3)}")); + Assert.That(target.Int1, Is.EqualTo(123)); + Assert.That(target.Int2, Is.EqualTo(2)); + Assert.That(target.Int3, Is.EqualTo(3)); + Assert.That(target.Int4, Is.EqualTo(456)); + Assert.That(target.Int5, Is.EqualTo(5)); + } + + sealed class LoadIgnoreTarget + { + [FieldLoader.Ignore] + public int Int1 = 1; + [FieldLoader.Ignore] + public int Int2 = 2; + + public int Int3 = 3; + } + + [Test] + public void Load_Ignore() + { + var target = new LoadIgnoreTarget(); + var yaml = new MiniYaml( + null, + [ + new MiniYamlNode(nameof(LoadIgnoreTarget.Int1), "123"), + new MiniYamlNode(nameof(LoadIgnoreTarget.Int3), "456"), + ]); + + FieldLoader.Load(target, yaml); + + Assert.That(target.Int1, Is.EqualTo(1)); + Assert.That(target.Int2, Is.EqualTo(2)); + Assert.That(target.Int3, Is.EqualTo(456)); + } + + sealed class LoadUsingTarget + { + [FieldLoader.LoadUsing(nameof(LoadInt))] + public int Int1 = 1; + [FieldLoader.LoadUsing(nameof(LoadInt), true)] + public int Int2 = 2; + + [FieldLoader.LoadUsing(nameof(LoadInt))] + public int Int3 = 3; + [FieldLoader.LoadUsing(nameof(LoadInt), true)] + public int Int4 = 4; + + public int Int5 = 5; + + static object LoadInt(MiniYaml yaml) => Exts.ParseInt32Invariant(yaml.NodeWithKey("ForLoadUsing").Value.Value); + } + + [Test] + public void Load_Using() + { + var target = new LoadUsingTarget(); + var yaml = new MiniYaml( + null, + [ + new MiniYamlNode("ForLoadUsing", "100"), + new MiniYamlNode(nameof(LoadUsingTarget.Int1), "12"), + new MiniYamlNode(nameof(LoadUsingTarget.Int2), "34"), + new MiniYamlNode(nameof(LoadUsingTarget.Int5), "56"), + ]); + + void Act() => FieldLoader.Load(target, yaml); + + Assert.That(Act, + Throws.TypeOf().And + .Message.EqualTo(nameof(LoadRequiredTarget.Int4))); + Assert.That(target.Int1, Is.EqualTo(100)); + Assert.That(target.Int2, Is.EqualTo(100)); + Assert.That(target.Int3, Is.EqualTo(100)); + Assert.That(target.Int4, Is.EqualTo(4)); + Assert.That(target.Int5, Is.EqualTo(56)); + } + + sealed class LoadUsingMissingTarget + { + [FieldLoader.LoadUsing("unknown")] + public int Int = 1; + } + + [Test] + public void Load_UsingMissing() + { + var target = new LoadUsingMissingTarget(); + var yaml = new MiniYaml(null); + + void Act() => FieldLoader.Load(target, yaml); + + Assert.That(Act, + Throws.TypeOf().And + .Message.EqualTo("LoadUsingMissingTarget does not specify a loader function 'unknown'")); + Assert.That(target.Int, Is.EqualTo(1)); + } + } +} diff --git a/OpenRA.Test/OpenRA.Game/FieldSaverTest.cs b/OpenRA.Test/OpenRA.Game/FieldSaverTest.cs new file mode 100644 index 0000000000..410b99668c --- /dev/null +++ b/OpenRA.Test/OpenRA.Game/FieldSaverTest.cs @@ -0,0 +1,265 @@ +#region Copyright & License Information +/* + * Copyright (c) The OpenRA Developers and Contributors + * This file is part of OpenRA, which is free software. It is made + * available to you 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. For more + * information, see COPYING. + */ +#endregion + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using NUnit.Framework; +using OpenRA.Primitives; +using OpenRA.Support; + +namespace OpenRA.Test +{ + [TestFixture] + sealed class FieldSaverTest + { + static IEnumerable FormatValue_Primitive_TestCases() + { + return + [ + new TestCaseData(123), + new TestCaseData((ushort)123), + new TestCaseData(123L), + new TestCaseData(123.4f), + new TestCaseData(123m), + new TestCaseData("test"), + new TestCaseData(Color.CornflowerBlue), + new TestCaseData(new Hotkey(Keycode.A, Modifiers.Shift)), + new TestCaseData(new WDist(123)), + new TestCaseData(new WVec(123, 456, 789)), + new TestCaseData(new WPos(123, 456, 789)), + new TestCaseData(new WAngle(123)), + new TestCaseData(new WRot(new WAngle(123), new WAngle(456), new WAngle(789))), + new TestCaseData(new CPos(123, 456)), + new TestCaseData(new CPos(123, 456, 78)), + new TestCaseData(new CVec(123, 456)), + new TestCaseData(new BooleanExpression("true")), + new TestCaseData(new IntegerExpression("1 + 2")), + new TestCaseData(MapGridType.RectangularIsometric), + new TestCaseData(SystemActors.World | SystemActors.EditorWorld), + new TestCaseData(true), + new TestCaseData(new Size(123, 456)), + new TestCaseData(new int2(123, 456)), + new TestCaseData(new float2(123, 456)), + new TestCaseData(new float3(123, 456, 789)), + new TestCaseData(new Rectangle(123, 456, 789, 123)), + ]; + } + + [TestCaseSource(nameof(FormatValue_Primitive_TestCases))] + public void FormatVaue_Primitive(T expected) + { + var actual = FieldSaver.FormatValue(expected); + + Assert.That(actual, Is.EqualTo(expected.ToString())); + } + + [Test] + public void FormatValue_Null() + { + var actual = FieldSaver.FormatValue(null); + + Assert.That(actual, Is.EqualTo("")); + } + + [Test] + public void FormatValue_DateTime() + { + var input = new DateTime(2000, 1, 1); + + var actual = FieldSaver.FormatValue(input); + + Assert.That(actual, Is.EqualTo(input.ToString("yyyy-MM-dd HH-mm-ss", CultureInfo.InvariantCulture))); + } + + [Test] + public void FormatValue_WVecArray() + { + var actual = FieldSaver.FormatValue(new WVec[] { new(1, 2, 3), new(4, 5, 6) }); + + Assert.That(actual, Is.EqualTo("1,2,3, 4,5,6")); + } + + [Test] + public void FormatValue_CPosArray() + { + var actual = FieldSaver.FormatValue(new CPos[] { new(1, 2), new(3, 4), new(5, 6) }); + + Assert.That(actual, Is.EqualTo("1,2, 3,4, 5,6")); + } + + [Test] + public void FormatValue_CVecArray() + { + var actual = FieldSaver.FormatValue(new CVec[] { new(1, 2), new(3, 4), new(5, 6) }); + + Assert.That(actual, Is.EqualTo("1,2, 3,4, 5,6")); + } + + [Test] + public void FormatValue_int2Array() + { + var actual = FieldSaver.FormatValue(new int2[] { new(1, 2), new(3, 4), new(5, 6) }); + + Assert.That(actual, Is.EqualTo("1,2, 3,4, 5,6")); + } + + [TestCase(null, "")] + [TestCase(123, "123")] + public void FormatValue_Nullable(int? input, string expected) + { + var actual = FieldSaver.FormatValue(input); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(null, "")] + [TestCase(new int[] { }, "")] + [TestCase(new int[] { 1 }, "1")] + [TestCase(new int[] { 1, 2, 3 }, "1, 2, 3")] + [TestCase(new int[] { 1, 1, 2, 2, 3 }, "1, 1, 2, 2, 3")] + [TestCase(new int[] { 1, 1, 1, 1 }, "1, 1, 1, 1")] + public void FormatValue_Array(int[] input, string expected) + { + var actual = FieldSaver.FormatValue(input); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(null, "")] + [TestCase(new int[] { }, "")] + [TestCase(new int[] { 1 }, "1")] + [TestCase(new int[] { 1, 2, 3 }, "1, 2, 3")] + [TestCase(new int[] { 1, 1, 2, 2, 3 }, "1, 1, 2, 2, 3")] + [TestCase(new int[] { 1, 1, 1, 1 }, "1, 1, 1, 1")] + public void FormatValue_List(int[] input, string expected) + { + var actual = FieldSaver.FormatValue(input?.ToList()); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(null, "")] + [TestCase(new int[] { }, "")] + [TestCase(new int[] { 1 }, "1")] + [TestCase(new int[] { 1, 2, 3 }, "1, 2, 3")] + [TestCase(new int[] { 1, 1, 2, 2, 3 }, "1, 2, 3")] + [TestCase(new int[] { 1, 1, 1, 1 }, "1")] + public void FormatValue_HashSet(int[] input, string expected) + { + var actual = FieldSaver.FormatValue(input?.ToHashSet()); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase("")] + [TestCase("1")] + [TestCase("1,2,3")] + [TestCase("1,1,2,2,3")] + [TestCase("1,1,1,1")] + public void FormatValue_BitSet(string input) + { + var actual = FieldSaver.FormatValue(new BitSet(input)); + + Assert.That(actual, Is.EqualTo(input)); + } + + [Test] + public void FormatValue_Dictionary() + { + var input = new Dictionary { { 12, 34 }, { 56, 78 } }; + + var actual = FieldSaver.FormatValue(input); + + Assert.That(actual, Is.EqualTo($"12: 34{Environment.NewLine}56: 78{Environment.NewLine}")); + } + + sealed class FieldTarget + { + public int Int = 123; + } + + [Test] + public void FormatValue_Field() + { + var actual = FieldSaver.FormatValue(new FieldTarget(), typeof(FieldTarget).GetField(nameof(FieldTarget.Int))); + + Assert.That(actual, Is.EqualTo("123")); + } + + [Test] + public void SaveField() + { + var actual = FieldSaver.SaveField(new FieldTarget(), nameof(FieldTarget.Int)); + + var actualString = MiniYamlExts.WriteToString([actual]); + var expectedString = MiniYamlExts.WriteToString([new MiniYamlNode(nameof(FieldTarget.Int), "123")]); + Assert.That(actualString, Is.EqualTo(expectedString)); + } + + sealed class SaveTarget + { + public int Int = 123; + public string String = "test"; + public int[] IntArray = [1, 2, 3]; + public Dictionary StringDictionary = new() { { "a", "b" }, { "c", "d" } }; + } + + [Test] + public void Save() + { + var expected = new MiniYaml( + null, + [ + new MiniYamlNode(nameof(SaveTarget.Int), "123"), + new MiniYamlNode(nameof(SaveTarget.String), "test"), + new MiniYamlNode(nameof(SaveTarget.IntArray), "1, 2, 3"), + new MiniYamlNode( + nameof(SaveTarget.StringDictionary), + new MiniYaml(null, [new MiniYamlNode("a", "b"), new MiniYamlNode("c", "d")])), + ]); + + var actual = FieldSaver.Save(new SaveTarget()); + + var actualString = MiniYamlExts.WriteToString(actual.Nodes); + var expectedString = MiniYamlExts.WriteToString(expected.Nodes); + Assert.That(actual.Value, Is.EqualTo(expected.Value)); + Assert.That(actualString, Is.EqualTo(expectedString)); + } + + [Test] + public void SaveDifferences() + { + var expected = new MiniYaml( + null, + [ + new MiniYamlNode(nameof(SaveTarget.String), ""), + new MiniYamlNode(nameof(SaveTarget.IntArray), "1, 2, 4"), + ]); + + var actual = FieldSaver.SaveDifferences(new SaveTarget { IntArray = [1, 2, 4], String = null }, new SaveTarget()); + + var actualString = MiniYamlExts.WriteToString(actual.Nodes); + var expectedString = MiniYamlExts.WriteToString(expected.Nodes); + Assert.That(actual.Value, Is.EqualTo(expected.Value)); + Assert.That(actualString, Is.EqualTo(expectedString)); + } + + [Test] + public void SaveDifferences_DifferentTypes() + { + static void Act() => FieldSaver.SaveDifferences(new object(), new SaveTarget()); + + Assert.That(Act, Throws.TypeOf().And.Message.EqualTo("FieldSaver: can't diff objects of different types")); + } + } +}