From 797c71e500d9342e558dc464609b570d36d8615f Mon Sep 17 00:00:00 2001 From: RoosterDragon Date: Fri, 7 Nov 2025 20:16:55 +0000 Subject: [PATCH] Add FieldLoader/Saver support for ImmutableArray, FrozenSet, FrozenDictionary. As config is often meant to be loaded and then never modified, it is helpful to support collections that are read-only after creation. This allows various classes that load config from YAML and elsewhere to deserialize straight into read-only collections and enforce invariants around data mutation. --- OpenRA.Game/FieldLoader.cs | 113 +++++++++++++++-- OpenRA.Game/FieldSaver.cs | 30 ++++- .../Lint/CheckFluentReferences.cs | 17 ++- OpenRA.Mods.Common/Lint/LintExts.cs | 45 ++++--- OpenRA.Mods.Common/Util.cs | 15 ++- OpenRA.Test/OpenRA.Game/FieldLoaderTest.cs | 114 ++++++++++++++++++ OpenRA.Test/OpenRA.Game/FieldSaverTest.cs | 78 ++++++++++++ 7 files changed, 376 insertions(+), 36 deletions(-) diff --git a/OpenRA.Game/FieldLoader.cs b/OpenRA.Game/FieldLoader.cs index ab04eff27f..24f262bcae 100644 --- a/OpenRA.Game/FieldLoader.cs +++ b/OpenRA.Game/FieldLoader.cs @@ -10,7 +10,9 @@ #endregion using System; +using System.Collections.Frozen; using System.Collections.Generic; +using System.Collections.Immutable; using System.ComponentModel; using System.Globalization; using System.IO; @@ -99,6 +101,9 @@ namespace OpenRA { typeof(HashSet<>), ParseHashSetOrList }, { typeof(List<>), ParseHashSetOrList }, { typeof(Dictionary<,>), ParseDictionary }, + { typeof(ImmutableArray<>), ParseImmutableArray }, + { typeof(FrozenSet<>), ParseFrozenSet }, + { typeof(FrozenDictionary<,>), ParseFrozenDictionary }, { typeof(BitSet<>), ParseBitSet }, { typeof(Nullable<>), ParseNullable }, }; @@ -107,6 +112,24 @@ namespace OpenRA static readonly object BoxedFalse = false; static readonly object[] BoxedInts = Exts.MakeArray(33, i => (object)i); + static readonly MethodInfo ToImmutableArray = + typeof(ImmutableArray) + .GetMethods() + .Single(m => + m.Name == nameof(ImmutableArray.ToImmutableArray) && + m.GetParameters()?.First().ParameterType.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + + static readonly MethodInfo ToFrozenSet = + typeof(FrozenSet) + .GetMethod(nameof(FrozenSet.ToFrozenSet)); + + static readonly MethodInfo ToFrozenDictionary = + typeof(FrozenDictionary) + .GetMethods() + .Single(m => + m.Name == nameof(FrozenDictionary.ToFrozenDictionary) && + m.GetParameters().Length == 2); + static object ParseInt(string fieldName, Type fieldType, string value) { if (Exts.TryParseInt32Invariant(value, out var res)) @@ -508,6 +531,24 @@ namespace OpenRA return InvalidValueAction(value, fieldType, fieldName); } + static object ParseArray(string fieldName, Type fieldType, string value) + { + var elementType = fieldType.GetElementType(); + + if (value == null) + return typeof(Array) + .GetMethod(nameof(Array.Empty)) + .MakeGenericMethod(elementType) + .Invoke(null, null); + + var parts = value.Split(Comma, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var ret = Array.CreateInstance(elementType, parts.Length); + for (var i = 0; i < parts.Length; i++) + ret.SetValue(GetValue(fieldName, elementType, parts[i]), i); + return ret; + } + static object ParseHashSetOrList(string fieldName, Type fieldType, string value, MiniYaml yaml) { if (value == null) @@ -546,6 +587,66 @@ namespace OpenRA return dict; } + static object ParseImmutableArray(string fieldName, Type fieldType, string value, MiniYaml yaml) + { + var typeArgs = fieldType.GenericTypeArguments; + + if (value == null) + return typeof(ImmutableArray<>).MakeGenericType(typeArgs) + .GetField(nameof(ImmutableArray.Empty)) + .GetValue(null); + + object array; + if (typeArgs[0] == typeof(WVec)) + array = ParseWVecArray(fieldName, typeArgs[0].MakeArrayType(), value); + else if (typeArgs[0] == typeof(CPos)) + array = ParseCPosArray(fieldName, typeArgs[0].MakeArrayType(), value); + else if (typeArgs[0] == typeof(CVec)) + array = ParseCVecArray(fieldName, typeArgs[0].MakeArrayType(), value); + else if (typeArgs[0] == typeof(int2)) + array = ParseInt2Array(fieldName, typeArgs[0].MakeArrayType(), value); + else + array = ParseArray(fieldName, typeArgs[0].MakeArrayType(), value); + + var toImmutableArray = ToImmutableArray.MakeGenericMethod(typeArgs); + + return toImmutableArray.Invoke(null, [array]); + } + + static object ParseFrozenSet(string fieldName, Type fieldType, string value, MiniYaml yaml) + { + var typeArgs = fieldType.GenericTypeArguments; + + if (value == null) + return typeof(FrozenSet<>).MakeGenericType(typeArgs) + .GetProperty(nameof(FrozenSet.Empty)) + .GetValue(null); + + var set = + ParseHashSetOrList(fieldName, typeof(HashSet<>).MakeGenericType(typeArgs), value, yaml); + + var toFrozenSet = ToFrozenSet.MakeGenericMethod(typeArgs); + + return toFrozenSet.Invoke(null, [set, null]); + } + + static object ParseFrozenDictionary(string fieldName, Type fieldType, string value, MiniYaml yaml) + { + var typeArgs = fieldType.GenericTypeArguments; + + if (yaml == null) + return typeof(FrozenDictionary<,>).MakeGenericType(typeArgs) + .GetProperty(nameof(FrozenDictionary.Empty)) + .GetValue(null); + + var dict = + ParseDictionary(fieldName, typeof(Dictionary<,>).MakeGenericType(typeArgs), value, yaml); + + var toFrozenDict = ToFrozenDictionary.MakeGenericMethod(typeArgs); + + return toFrozenDict.Invoke(null, [dict, null]); + } + static object ParseBitSet(string fieldName, Type fieldType, string value, MiniYaml yaml) { if (value != null) @@ -685,17 +786,7 @@ namespace OpenRA return parseFunc(fieldName, fieldType, value); if (fieldType.IsArray && fieldType.GetArrayRank() == 1) - { - if (value == null) - return Array.CreateInstance(fieldType.GetElementType(), 0); - - 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]), i); - return ret; - } + return ParseArray(fieldName, fieldType, value); if (fieldType.IsEnum) return ParseEnum(fieldName, fieldType, value); diff --git a/OpenRA.Game/FieldSaver.cs b/OpenRA.Game/FieldSaver.cs index f42d6cbc9b..2566012200 100644 --- a/OpenRA.Game/FieldSaver.cs +++ b/OpenRA.Game/FieldSaver.cs @@ -10,7 +10,9 @@ #endregion using System; +using System.Collections.Frozen; using System.Collections.Generic; +using System.Collections.Immutable; using System.ComponentModel; using System.Globalization; using System.Linq; @@ -78,11 +80,35 @@ namespace OpenRA if (t.IsArray && t.GetArrayRank() == 1) return ((Array)v).Cast().Select(FormatValue).JoinWith(", "); - if (t.IsGenericType && (t.GetGenericTypeDefinition() == typeof(HashSet<>) || t.GetGenericTypeDefinition() == typeof(List<>))) + if (t.IsGenericType && + t.GetGenericTypeDefinition() == typeof(ImmutableArray<>)) + { + try + { + return ((System.Collections.IEnumerable)v).Cast().Select(FormatValue).JoinWith(", "); + } + catch (InvalidOperationException) + { + return ""; + } + } + + if (t.IsGenericType && + (t.GetGenericTypeDefinition() == typeof(List<>) || + t.GetGenericTypeDefinition() == typeof(HashSet<>) || + t.GetGenericTypeDefinition() + .BaseTypes() + .Select(bt => bt.IsGenericType ? bt.GetGenericTypeDefinition() : null) + .Any(bt => bt == typeof(FrozenSet<>)))) return ((System.Collections.IEnumerable)v).Cast().Select(FormatValue).JoinWith(", "); // This is only for documentation generation - if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + if (t.IsGenericType && + (t.GetGenericTypeDefinition() == typeof(Dictionary<,>) || + t.GetGenericTypeDefinition() + .BaseTypes() + .Select(bt => bt.IsGenericType ? bt.GetGenericTypeDefinition() : null) + .Any(bt => bt == typeof(FrozenDictionary<,>)))) { var result = new StringBuilder(); var dict = (System.Collections.IDictionary)v; diff --git a/OpenRA.Mods.Common/Lint/CheckFluentReferences.cs b/OpenRA.Mods.Common/Lint/CheckFluentReferences.cs index 3d75571449..f4a216aa46 100644 --- a/OpenRA.Mods.Common/Lint/CheckFluentReferences.cs +++ b/OpenRA.Mods.Common/Lint/CheckFluentReferences.cs @@ -274,15 +274,22 @@ namespace OpenRA.Mods.Common.Lint foreach (var o in (IEnumerable)fieldValue) ExtractFluentKeys(modData, o, prefix, keys); - if (type.IsGenericType && - (type.GetGenericTypeDefinition() == typeof(Dictionary<,>) || - type.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>))) + Type dictionaryInterface = null; + if (type.IsGenericType) + { + if (type.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)) + dictionaryInterface = type; + else + dictionaryInterface = type.GetInterface(typeof(IReadOnlyDictionary<,>).FullName); + } + + if (dictionaryInterface != null) { // Use an intermediate list to cover the unlikely case where both keys and values are lintable. if (dictionaryReference.HasFlag(LintDictionaryReference.Keys)) { IEnumerable fieldKeys = ((IDictionary)fieldValue).Keys; - if (typeof(IEnumerable).IsAssignableFrom(type.GenericTypeArguments[0])) + if (typeof(IEnumerable).IsAssignableFrom(dictionaryInterface.GenericTypeArguments[0])) fieldKeys = ((ICollection>)fieldKeys).SelectMany(v => v); foreach (var k in fieldKeys) @@ -292,7 +299,7 @@ namespace OpenRA.Mods.Common.Lint if (dictionaryReference.HasFlag(LintDictionaryReference.Values)) { IEnumerable fieldValues = ((IDictionary)fieldValue).Values; - if (typeof(IEnumerable).IsAssignableFrom(type.GenericTypeArguments[1])) + if (typeof(IEnumerable).IsAssignableFrom(dictionaryInterface.GenericTypeArguments[1])) fieldValues = ((ICollection>)fieldValues).SelectMany(v => v); foreach (var v in fieldValues) diff --git a/OpenRA.Mods.Common/Lint/LintExts.cs b/OpenRA.Mods.Common/Lint/LintExts.cs index 1fa45a89b6..f3ced4bd39 100644 --- a/OpenRA.Mods.Common/Lint/LintExts.cs +++ b/OpenRA.Mods.Common/Lint/LintExts.cs @@ -36,19 +36,26 @@ namespace OpenRA.Mods.Common.Lint return expr != null ? expr.Variables : []; } - if (type.IsGenericType && - (type.GetGenericTypeDefinition() == typeof(Dictionary<,>) || - type.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>))) + Type dictionaryInterface = null; + if (type.IsGenericType) + { + if (type.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)) + dictionaryInterface = type; + else + dictionaryInterface = type.GetInterface(typeof(IReadOnlyDictionary<,>).FullName); + } + + if (dictionaryInterface != null) { // Use an intermediate list to cover the unlikely case where both keys and values are lintable. var dictionaryValues = new List(); - if (dictionaryReference.HasFlag(LintDictionaryReference.Keys) && type.GenericTypeArguments[0] == typeof(string)) + if (dictionaryReference.HasFlag(LintDictionaryReference.Keys) && dictionaryInterface.GenericTypeArguments[0] == typeof(string)) dictionaryValues.AddRange((IEnumerable)((IDictionary)fieldInfo.GetValue(ruleInfo)).Keys); - if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && type.GenericTypeArguments[1] == typeof(string)) + if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && dictionaryInterface.GenericTypeArguments[1] == typeof(string)) dictionaryValues.AddRange((IEnumerable)((IDictionary)fieldInfo.GetValue(ruleInfo)).Values); - if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && type.GenericTypeArguments[1] == typeof(IEnumerable)) + if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && dictionaryInterface.GenericTypeArguments[1] == typeof(IEnumerable)) foreach (var row in (IEnumerable>)((IDictionary)fieldInfo.GetValue(ruleInfo)).Values) dictionaryValues.AddRange(row); @@ -58,9 +65,6 @@ namespace OpenRA.Mods.Common.Lint var supportedTypes = new[] { "string", "IEnumerable", - "Dictionary (LintDictionaryReference.Keys)", - "Dictionary (LintDictionaryReference.Values)", - "Dictionary> (LintDictionaryReference.Values)", "IReadOnlyDictionary (LintDictionaryReference.Keys)", "IReadOnlyDictionary (LintDictionaryReference.Values)", "IReadOnlyDictionary> (LintDictionaryReference.Values)", @@ -88,17 +92,26 @@ namespace OpenRA.Mods.Common.Lint return expr != null ? expr.Variables : []; } - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + Type dictionaryInterface = null; + if (type.IsGenericType) + { + if (type.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>)) + dictionaryInterface = type; + else + dictionaryInterface = type.GetInterface(typeof(IReadOnlyDictionary<,>).FullName); + } + + if (dictionaryInterface != null) { // Use an intermediate list to cover the unlikely case where both keys and values are lintable. var dictionaryValues = new List(); - if (dictionaryReference.HasFlag(LintDictionaryReference.Keys) && type.GenericTypeArguments[0] == typeof(string)) + if (dictionaryReference.HasFlag(LintDictionaryReference.Keys) && dictionaryInterface.GenericTypeArguments[0] == typeof(string)) dictionaryValues.AddRange((IEnumerable)((IDictionary)propertyInfo.GetValue(ruleInfo)).Keys); - if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && type.GenericTypeArguments[1] == typeof(string)) + if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && dictionaryInterface.GenericTypeArguments[1] == typeof(string)) dictionaryValues.AddRange((IEnumerable)((IDictionary)propertyInfo.GetValue(ruleInfo)).Values); - if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && type.GenericTypeArguments[1] == typeof(IEnumerable)) + if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && dictionaryInterface.GenericTypeArguments[1] == typeof(IEnumerable)) foreach (var row in (IEnumerable>)((IDictionary)propertyInfo.GetValue(ruleInfo)).Values) dictionaryValues.AddRange(row); @@ -108,9 +121,9 @@ namespace OpenRA.Mods.Common.Lint var supportedTypes = new[] { "string", "IEnumerable", - "Dictionary (LintDictionaryReference.Keys)", - "Dictionary (LintDictionaryReference.Values)", - "Dictionary> (LintDictionaryReference.Values)", + "IReadOnlyDictionary (LintDictionaryReference.Keys)", + "IReadOnlyDictionary (LintDictionaryReference.Values)", + "IReadOnlyDictionary> (LintDictionaryReference.Values)", "BooleanExpression", "IntegerExpression" }; diff --git a/OpenRA.Mods.Common/Util.cs b/OpenRA.Mods.Common/Util.cs index f0c7453d57..71d870562b 100644 --- a/OpenRA.Mods.Common/Util.cs +++ b/OpenRA.Mods.Common/Util.cs @@ -10,6 +10,7 @@ #endregion using System; +using System.Collections.Frozen; using System.Collections.Generic; using System.ComponentModel; using System.Linq; @@ -270,10 +271,20 @@ namespace OpenRA.Mods.Common if (t.IsEnum) return $"{t.Name} (enum)"; - if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(HashSet<>)) + if (t.IsGenericType && + (t.GetGenericTypeDefinition() == typeof(HashSet<>) || + t.GetGenericTypeDefinition() + .BaseTypes() + .Select(bt => bt.IsGenericType ? bt.GetGenericTypeDefinition() : null) + .Any(bt => bt == typeof(FrozenSet<>)))) return $"Set of {t.GetGenericArguments().Select(FriendlyTypeName).First()}"; - if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + if (t.IsGenericType && + (t.GetGenericTypeDefinition() == typeof(Dictionary<,>) || + t.GetGenericTypeDefinition() + .BaseTypes() + .Select(bt => bt.IsGenericType ? bt.GetGenericTypeDefinition() : null) + .Any(bt => bt == typeof(FrozenDictionary<,>)))) { var args = t.GetGenericArguments().Select(FriendlyTypeName).ToArray(); return $"Dictionary with Key: {args[0]}, Value: {args[1]}"; diff --git a/OpenRA.Test/OpenRA.Game/FieldLoaderTest.cs b/OpenRA.Test/OpenRA.Game/FieldLoaderTest.cs index e0e5c5d0dc..fa64904278 100644 --- a/OpenRA.Test/OpenRA.Game/FieldLoaderTest.cs +++ b/OpenRA.Test/OpenRA.Game/FieldLoaderTest.cs @@ -10,7 +10,9 @@ #endregion using System; +using System.Collections.Frozen; using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Reflection; @@ -275,6 +277,14 @@ namespace OpenRA.Test Assert.That(actual, Is.EqualTo(expected)); } + [Test] + public void GetValue_NullString() + { + var actual = FieldLoader.GetValue("field", null); + + Assert.That(actual, Is.Null); + } + [Test] public void GetValue_BooleanExpression() { @@ -372,6 +382,38 @@ namespace OpenRA.Test Assert.That(actual, Is.EqualTo(new int2[] { new(1, 2), new(3, 4), new(5, 6) })); } + [Test] + public void GetValue_WVecImmutableArray() + { + 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_CPosImmutableArray() + { + 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_CVecImmutableArray() + { + 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_int2ImmutableArray() + { + 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)] @@ -459,9 +501,54 @@ namespace OpenRA.Test Assert.That(actual, Is.Empty); } + [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_ImmutableArray(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_FrozenSet(string input, int[] expected) + { + var actual = FieldLoader.GetValue>("field", input); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [TestCase(null)] + [TestCase("")] + [TestCase("1")] + [TestCase("1,2")] + public void GetValue_FrozenDictionary(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>)])] + [TestCase(TypeArgs = [typeof(ImmutableArray>)])] + [TestCase(TypeArgs = [typeof(FrozenSet>)])] public void GetValue_NestedCollections() { var actual = FieldLoader.GetValue("field", "1,2,3"); @@ -590,6 +677,33 @@ namespace OpenRA.Test Assert.That(target.Dictionary, Is.EquivalentTo(new Dictionary { { 12, 34 }, { 56, 78 } })); } + sealed class LoadFrozenDictionaryTarget + { + public FrozenDictionary Dictionary; + } + + [Test] + public void Load_FrozenDictionary() + { + var target = new LoadFrozenDictionaryTarget(); + var yaml = new MiniYaml( + null, + [ + new MiniYamlNode( + nameof(LoadFrozenDictionaryTarget.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] diff --git a/OpenRA.Test/OpenRA.Game/FieldSaverTest.cs b/OpenRA.Test/OpenRA.Game/FieldSaverTest.cs index 410b99668c..3cd35cb5d8 100644 --- a/OpenRA.Test/OpenRA.Game/FieldSaverTest.cs +++ b/OpenRA.Test/OpenRA.Game/FieldSaverTest.cs @@ -10,7 +10,9 @@ #endregion using System; +using System.Collections.Frozen; using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; using System.Linq; using NUnit.Framework; @@ -113,6 +115,38 @@ namespace OpenRA.Test Assert.That(actual, Is.EqualTo("1,2, 3,4, 5,6")); } + [Test] + public void FormatValue_WVecImmutableArray() + { + var actual = FieldSaver.FormatValue(new WVec[] { new(1, 2, 3), new(4, 5, 6) }.ToImmutableArray()); + + Assert.That(actual, Is.EqualTo("1,2,3, 4,5,6")); + } + + [Test] + public void FormatValue_CPosImmutableArray() + { + var actual = FieldSaver.FormatValue(new CPos[] { new(1, 2), new(3, 4), new(5, 6) }.ToImmutableArray()); + + Assert.That(actual, Is.EqualTo("1,2, 3,4, 5,6")); + } + + [Test] + public void FormatValue_CVecImmutableArray() + { + var actual = FieldSaver.FormatValue(new CVec[] { new(1, 2), new(3, 4), new(5, 6) }.ToImmutableArray()); + + Assert.That(actual, Is.EqualTo("1,2, 3,4, 5,6")); + } + + [Test] + public void FormatValue_int2ImmutableArray() + { + var actual = FieldSaver.FormatValue(new int2[] { new(1, 2), new(3, 4), new(5, 6) }.ToImmutableArray()); + + Assert.That(actual, Is.EqualTo("1,2, 3,4, 5,6")); + } + [TestCase(null, "")] [TestCase(123, "123")] public void FormatValue_Nullable(int? input, string expected) @@ -183,6 +217,50 @@ namespace OpenRA.Test Assert.That(actual, Is.EqualTo($"12: 34{Environment.NewLine}56: 78{Environment.NewLine}")); } + [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_ImmutableArray(int[] input, string expected) + { + var actual = FieldSaver.FormatValue(input?.ToImmutableArray()); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void FormatValue_ImmutableArray_Default() + { + var actual = FieldSaver.FormatValue(default(ImmutableArray)); + + Assert.That(actual, Is.Empty); + } + + [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_FrozenSet(int[] input, string expected) + { + var actual = FieldSaver.FormatValue(input?.ToFrozenSet()); + + Assert.That(actual, Is.EqualTo(expected)); + } + + [Test] + public void FormatValue_FrozenDictionary() + { + var input = new Dictionary { { 12, 34 }, { 56, 78 } }.ToFrozenDictionary(); + + 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;