Skip to content

Commit

Permalink
Fix saving legacy summaries (#423)
Browse files Browse the repository at this point in the history
As reported [in
Discord](https://discord.com/channels/560199483065892894/1210135941700919296/1342927292254523574),
the legacy summary page cannot be saved.

I broke the serialization rules, and I don't think it's the first time,
so I also added some tests to watch the serializable types and complain
when they change and/or are out of compliance.

`SerializationTreeChangeDetection.TreeHasNotChanged` might turn out to
be too aggressive, but it seemed like a good idea.
  • Loading branch information
shpaass authored Feb 24, 2025
2 parents 47eccf8 + f06fcac commit 3b2d736
Show file tree
Hide file tree
Showing 7 changed files with 491 additions and 8 deletions.
339 changes: 339 additions & 0 deletions Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs

Large diffs are not rendered by default.

144 changes: 144 additions & 0 deletions Yafc.Model.Tests/Serialization/SerializationTypeValidation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using Xunit;

namespace Yafc.Model.Serialization.Tests;

public class SerializationTypeValidation {
[Fact]
// Ensure that all concrete types derived from ModelObject obey the serialization rules.
public void ModelObjects_AreSerializable() {
var types = AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes())
.Where(t => typeof(ModelObject).IsAssignableFrom(t) && !t.IsAbstract);

foreach (Type type in types) {
ConstructorInfo constructor = FindConstructor(type);

PropertyInfo ownerProperty = type.GetProperty("owner");
if (ownerProperty != null) {
// If derived from ModelObject<T> (as tested by "There's an 'owner' property"), the first constructor parameter must be T.
Assert.True(constructor.GetParameters().Length > 0, $"The first constructor parameter for type {MakeTypeName(type)} should be the parent object.");
Type baseType = typeof(ModelObject<>).MakeGenericType(ownerProperty.PropertyType);
Assert.True(baseType.IsAssignableFrom(type), $"The first constructor parameter for type {MakeTypeName(type)} is not the parent type.");
}

// Cheating a bit here: Project is the only ModelObject that is not a ModelObject<T>, and its constructor has no parameters.
// So we're just skipping a parameter that doesn't exist.
AssertConstructorParameters(type, constructor.GetParameters().Skip(1));
AssertSettableProperties(type);
AssertDictionaryKeys(type);
}
}

[Fact]
// Ensure that all [Serializable] types in the Yafc namespace obey the serialization rules,
// except compiler-generated types and those in Yafc.Blueprints.
public void Serializables_AreSerializable() {
var types = AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes())
.Where(t => t.GetCustomAttribute<SerializableAttribute>() != null && t.FullName.StartsWith("Yafc.") && !t.FullName.StartsWith("Yafc.Blueprints"));

foreach (Type type in types.Where(type => type.GetCustomAttribute<CompilerGeneratedAttribute>() == null)) {
ConstructorInfo constructor = FindConstructor(type);

AssertConstructorParameters(type, constructor.GetParameters());
AssertSettableProperties(type);
AssertDictionaryKeys(type);
}
}

internal static ConstructorInfo FindConstructor(Type type) {
BindingFlags flags = BindingFlags.Instance;
if (type.GetCustomAttribute<DeserializeWithNonPublicConstructorAttribute>() != null) {
flags |= BindingFlags.NonPublic;
}
else {
flags |= BindingFlags.Public;
}

// The constructor (public or non-public, depending on the attribute) must exist
ConstructorInfo constructor = type.GetConstructors(flags).FirstOrDefault();
Assert.True(constructor != null, $"Could not find the constructor for type {MakeTypeName(type)}.");
return constructor;
}


private static void AssertConstructorParameters(Type type, IEnumerable<ParameterInfo> parameters) {
foreach (ParameterInfo parameter in parameters) {
// Constructor parameters must be IsValueSerializerSupported
Assert.True(ValueSerializer.IsValueSerializerSupported(parameter.ParameterType),
$"Constructor of type {MakeTypeName(type)} parameter '{parameter.Name}' should be a supported value type.");

// and must have a matching property
PropertyInfo property = type.GetProperty(parameter.Name);
Assert.True(property != null, $"Constructor of type {MakeTypeName(type)} parameter '{parameter.Name}' does not have a matching property.");
Assert.True(parameter.ParameterType == property.PropertyType,
$"Constructor of type {MakeTypeName(type)} parameter '{parameter.Name}' does not have the same type as its property.");
}
}

private static void AssertSettableProperties(Type type) {
foreach (PropertyInfo property in type.GetProperties().Where(p => p.GetSetMethod() != null)) {
if (property.GetCustomAttribute<SkipSerializationAttribute>() == null) {
// Properties with a public setter must be IsValueSerializerSupported
Assert.True(ValueSerializer.IsValueSerializerSupported(property.PropertyType),
$"The type of property {MakeTypeName(type)}.{property.Name} should be a supported value type.");
}
}
}

private void AssertDictionaryKeys(Type type) {
foreach (PropertyInfo property in type.GetProperties().Where(p => p.GetSetMethod() == null)) {
if (property.GetCustomAttribute<SkipSerializationAttribute>() == null) {
Type iDictionary = property.PropertyType.GetInterfaces()
.FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>));

if (iDictionary != null) {
if (ValueSerializer.IsValueSerializerSupported(iDictionary.GenericTypeArguments[0])
&& ValueSerializer.IsValueSerializerSupported(iDictionary.GenericTypeArguments[1])) {

object serializer = typeof(ValueSerializer<>).MakeGenericType(iDictionary.GenericTypeArguments[0]).GetField("Default").GetValue(null);
MethodInfo getJsonProperty = serializer.GetType().GetMethod(nameof(ValueSerializer<string>.GetJsonProperty));
// Dictionary keys must be serialized by an overridden ValueSerializer<T>.GetJsonProperty() method.
Assert.True(getJsonProperty != getJsonProperty.GetBaseDefinition(),
$"In {MakeTypeName(type)}.{property.Name}, the dictionary keys are of an unsupported type.");
}
}
}
}
}

internal static string MakeTypeName(Type type) {
if (type.IsGenericType) {
if (type.GetGenericTypeDefinition() == typeof(Nullable<>)) {
return MakeTypeName(type.GetGenericArguments()[0]) + '?';
}
StringBuilder result = new(type.Name.Split('`')[0]);
result.Append('<').AppendJoin(", ", type.GenericTypeArguments.Select(MakeTypeName)).Append('>');
return result.ToString();
}

if (type == typeof(bool)) { return "bool"; }
if (type == typeof(byte)) { return "byte"; }
if (type == typeof(sbyte)) { return "sbyte"; }
if (type == typeof(char)) { return "char"; }
if (type == typeof(short)) { return "short"; }
if (type == typeof(ushort)) { return "ushort"; }
if (type == typeof(int)) { return "int"; }
if (type == typeof(uint)) { return "uint"; }
if (type == typeof(long)) { return "long"; }
if (type == typeof(ulong)) { return "ulong"; }
if (type == typeof(nint)) { return "nint"; }
if (type == typeof(nuint)) { return "nuint"; }
if (type == typeof(float)) { return "float"; }
if (type == typeof(double)) { return "double"; }
if (type == typeof(decimal)) { return "decimal"; }
if (type == typeof(string)) { return "string"; }
if (type == typeof(object)) { return "object"; }

return type.Name;
}
}
6 changes: 3 additions & 3 deletions Yafc.Model/Model/ProductionSummary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,8 @@ public void SetMultiplier(float newMultiplier) {
}
}

public class ProductionSummaryColumn(ProductionSummary owner, IObjectWithQuality<Goods> goods) : ModelObject<ProductionSummary>(owner) {
public IObjectWithQuality<Goods> goods { get; } = goods ?? throw new ArgumentNullException(nameof(goods), "Object does not exist");
public class ProductionSummaryColumn(ProductionSummary owner, ObjectWithQuality<Goods> goods) : ModelObject<ProductionSummary>(owner) {
public ObjectWithQuality<Goods> goods { get; } = goods ?? throw new ArgumentNullException(nameof(goods), "Object does not exist");
}

public class ProductionSummary : ProjectPageContents, IComparer<(IObjectWithQuality<Goods> goods, float amount)> {
Expand All @@ -169,7 +169,7 @@ public class ProductionSummary : ProjectPageContents, IComparer<(IObjectWithQual
[SkipSerialization] public HashSet<IObjectWithQuality<Goods>> columnsExist { get; } = [];

public override void InitNew() {
columns.Add(new ProductionSummaryColumn(this, Database.electricity));
columns.Add(new ProductionSummaryColumn(this, new(Database.electricity.target, Quality.Normal)));
base.InitNew();
}

Expand Down
2 changes: 1 addition & 1 deletion Yafc.Model/Model/ProductionTableContent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public class RecipeRowCustomModule(ModuleTemplate owner, ObjectWithQuality<Modul
/// The template that determines what modules are (or will be) applied to a <see cref="RecipeRow"/>.
/// </summary>
/// <remarks>Immutable. To modify, call <see cref="GetBuilder"/>, modify the builder, and call <see cref="ModuleTemplateBuilder.Build"/>.</remarks>
[Serializable, DeserializeWithNonPublicConstructor]
[DeserializeWithNonPublicConstructor]
public class ModuleTemplate : ModelObject<ModelObject> {
/// <summary>
/// The beacon to use, if any, for the associated <see cref="RecipeRow"/>.
Expand Down
4 changes: 1 addition & 3 deletions Yafc.Model/Yafc.Model.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="Yafc.Model.Tests" />
<PackageReference Include="Google.OrTools" Version="9.11.4210" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Yafc.UI\Yafc.UI.csproj" />
</ItemGroup>

Expand Down
2 changes: 1 addition & 1 deletion Yafc/Workspace/ProductionSummary/ProductionSummaryView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ private void AddOrRemoveColumn(IObjectWithQuality<Goods> goods) {
}

if (!found) {
model.columns.Add(new ProductionSummaryColumn(model, goods));
model.columns.Add(new ProductionSummaryColumn(model, new(goods.target, goods.quality)));
}
}

Expand Down
2 changes: 2 additions & 0 deletions changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Version:
Date:
Features:
- Add related recipes to the Link Summary screen.
Fixes:
- (regression) Legacy summary pages could not be saved or loaded.
----------------------------------------------------------------------------------------------------------------------
Version: 2.8.1
Date: February 20th 2025
Expand Down

0 comments on commit 3b2d736

Please sign in to comment.