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.
This commit is contained in:
RoosterDragon
2025-11-07 20:16:55 +00:00
committed by Paul Chote
parent e53f2b6044
commit 797c71e500
7 changed files with 376 additions and 36 deletions

View File

@@ -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<object>.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<object>.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<object, object>.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);

View File

@@ -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<object>().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<object>().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<object>().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;

View File

@@ -274,15 +274,22 @@ namespace OpenRA.Mods.Common.Lint
foreach (var o in (IEnumerable<object>)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<object>).IsAssignableFrom(type.GenericTypeArguments[0]))
if (typeof(IEnumerable<object>).IsAssignableFrom(dictionaryInterface.GenericTypeArguments[0]))
fieldKeys = ((ICollection<IEnumerable<object>>)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<object>).IsAssignableFrom(type.GenericTypeArguments[1]))
if (typeof(IEnumerable<object>).IsAssignableFrom(dictionaryInterface.GenericTypeArguments[1]))
fieldValues = ((ICollection<IEnumerable<object>>)fieldValues).SelectMany(v => v);
foreach (var v in fieldValues)

View File

@@ -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<string>();
if (dictionaryReference.HasFlag(LintDictionaryReference.Keys) && type.GenericTypeArguments[0] == typeof(string))
if (dictionaryReference.HasFlag(LintDictionaryReference.Keys) && dictionaryInterface.GenericTypeArguments[0] == typeof(string))
dictionaryValues.AddRange((IEnumerable<string>)((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<string>)((IDictionary)fieldInfo.GetValue(ruleInfo)).Values);
if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && type.GenericTypeArguments[1] == typeof(IEnumerable<string>))
if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && dictionaryInterface.GenericTypeArguments[1] == typeof(IEnumerable<string>))
foreach (var row in (IEnumerable<IEnumerable<string>>)((IDictionary)fieldInfo.GetValue(ruleInfo)).Values)
dictionaryValues.AddRange(row);
@@ -58,9 +65,6 @@ namespace OpenRA.Mods.Common.Lint
var supportedTypes = new[]
{
"string", "IEnumerable<string>",
"Dictionary<string, T> (LintDictionaryReference.Keys)",
"Dictionary<T, string> (LintDictionaryReference.Values)",
"Dictionary<T, IEnumerable<string>> (LintDictionaryReference.Values)",
"IReadOnlyDictionary<string, T> (LintDictionaryReference.Keys)",
"IReadOnlyDictionary<T, string> (LintDictionaryReference.Values)",
"IReadOnlyDictionary<T, IEnumerable<string>> (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<string>();
if (dictionaryReference.HasFlag(LintDictionaryReference.Keys) && type.GenericTypeArguments[0] == typeof(string))
if (dictionaryReference.HasFlag(LintDictionaryReference.Keys) && dictionaryInterface.GenericTypeArguments[0] == typeof(string))
dictionaryValues.AddRange((IEnumerable<string>)((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<string>)((IDictionary)propertyInfo.GetValue(ruleInfo)).Values);
if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && type.GenericTypeArguments[1] == typeof(IEnumerable<string>))
if (dictionaryReference.HasFlag(LintDictionaryReference.Values) && dictionaryInterface.GenericTypeArguments[1] == typeof(IEnumerable<string>))
foreach (var row in (IEnumerable<IEnumerable<string>>)((IDictionary)propertyInfo.GetValue(ruleInfo)).Values)
dictionaryValues.AddRange(row);
@@ -108,9 +121,9 @@ namespace OpenRA.Mods.Common.Lint
var supportedTypes = new[]
{
"string", "IEnumerable<string>",
"Dictionary<string, T> (LintDictionaryReference.Keys)",
"Dictionary<T, string> (LintDictionaryReference.Values)",
"Dictionary<T, IEnumerable<string>> (LintDictionaryReference.Values)",
"IReadOnlyDictionary<string, T> (LintDictionaryReference.Keys)",
"IReadOnlyDictionary<T, string> (LintDictionaryReference.Values)",
"IReadOnlyDictionary<T, IEnumerable<string>> (LintDictionaryReference.Values)",
"BooleanExpression", "IntegerExpression"
};

View File

@@ -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]}";

View File

@@ -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<string>("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<ImmutableArray<WVec>>("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<ImmutableArray<CPos>>("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<ImmutableArray<CVec>>("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<ImmutableArray<int2>>("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<ImmutableArray<int>>("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<FrozenSet<int>>("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<FrozenDictionary<int, int>>("field", input);
Assert.That(actual, Is.Empty);
}
[TestCase(TypeArgs = [typeof(int[][])])]
[TestCase(TypeArgs = [typeof(List<List<int>>)])]
[TestCase(TypeArgs = [typeof(HashSet<HashSet<int>>)])]
[TestCase(TypeArgs = [typeof(ImmutableArray<ImmutableArray<int>>)])]
[TestCase(TypeArgs = [typeof(FrozenSet<FrozenSet<int>>)])]
public void GetValue_NestedCollections<T>()
{
var actual = FieldLoader.GetValue<T>("field", "1,2,3");
@@ -590,6 +677,33 @@ namespace OpenRA.Test
Assert.That(target.Dictionary, Is.EquivalentTo(new Dictionary<int, int> { { 12, 34 }, { 56, 78 } }));
}
sealed class LoadFrozenDictionaryTarget
{
public FrozenDictionary<int, int> 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<int, int> { { 12, 34 }, { 56, 78 } }));
}
sealed class LoadRequiredTarget
{
[FieldLoader.Require]

View File

@@ -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<int>));
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<int, int> { { 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;