From 44e4ddc580638912a5fbc65a16213875ba78fa60 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 06:35:33 +0000 Subject: [PATCH] Give semantic, vector and colour members their default in generated C# [patch] DefaultInitialiserFor only had arms for Int, Long, Float, Double, Bool, String and Enum, so a NumberDefault on a Semantic, Vector2/3/4 or colour member - all of which validation accepts - was written to the SchemaDefault attribute but got no initialiser, and a new instance started at zero where the C++ output started at the default. Build the default as a typed expression: a semantic type through its explicit conversion from the representation's literal, and a vector or colour with the number in every component, matching the C++ generator. A TravelsAsBytes struct gets its parameterless constructor as before, since that keys off the initialiser being non-empty. Fixes ktsu-dev/Schema#218 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011PMSp3sjbza8Td6fxS2Kjr --- Schema.Test/CodeGenerationRoundTripTests.cs | 62 +++++++++++++++++++ Schema/Generation/CSharpCodeGenerator.cs | 66 +++++++++++++++++---- 2 files changed, 118 insertions(+), 10 deletions(-) diff --git a/Schema.Test/CodeGenerationRoundTripTests.cs b/Schema.Test/CodeGenerationRoundTripTests.cs index d9a76c1..14e8ec7 100644 --- a/Schema.Test/CodeGenerationRoundTripTests.cs +++ b/Schema.Test/CodeGenerationRoundTripTests.cs @@ -230,6 +230,68 @@ public void TestAPromisingClassStartsAtItsDefaults() Assert.AreEqual(2.5f, triple.GetProperty("X")!.GetValue(instance)); } + /// + /// A member of a semantic type, of a vector or of a colour starts at its declared default, + /// the value the C++ generator starts it at. + /// + /// + /// Validation accepts a numeric default on all of these, and the attribute carrying it was + /// always written - but with no initialiser, a fresh instance started at zero. Reading the + /// attribute back cannot see that; only constructing an instance can. + /// + /// Whether the class is a struct, which needs a constructor + /// before its members may have initialisers. + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void TestSemanticVectorAndColourMembersStartAtTheirDefaults(bool travelsAsBytes) + { + Schema original = new(); + original.AddSemanticType("Kilograms".As())!.SetUnderlyingType(new Models.Types.Float()); + original.AddSemanticType("Count".As())!.SetUnderlyingType(new Models.Types.Int()); + original.AddSemanticType("Heavy".As())!.SetUnderlyingType( + new Models.Types.Semantic { SemanticTypeName = "Kilograms".As() }); + + SchemaClass body = original.AddClass("Body".As())!; + body.TravelsAsBytes = travelsAsBytes; + AddDefaulted(body, "Mass", new Models.Types.Semantic { SemanticTypeName = "Kilograms".As() }, 2.5); + AddDefaulted(body, "Offset", new Models.Types.Semantic { SemanticTypeName = "Kilograms".As() }, -1.5); + AddDefaulted(body, "Parts", new Models.Types.Semantic { SemanticTypeName = "Count".As() }, 3); + AddDefaulted(body, "Ballast", new Models.Types.Semantic { SemanticTypeName = "Heavy".As() }, 4); + AddDefaulted(body, "Scale", new Models.Types.Vector3(), 1); + AddDefaulted(body, "Cell", new Models.Types.Vector2 { ElementType = new Models.Types.Int() }, 2); + AddDefaulted(body, "Tint", new Models.Types.ColorRGBA(), 0.5); + + SchemaGenerationResult result = SchemaGenerator.Generate(original, CodeGenerationTests.ConfigureGenerator(original)); + Assert.IsTrue(result.IsSuccess, result.Message); + + Assembly assembly = GeneratedSourceCompiler.Compile(result.Files); + Type type = assembly.GetType("Generated.Body", throwOnError: true)!; + object instance = Activator.CreateInstance(type)!; + + Assert.AreEqual(2.5f, SemanticValue(instance, "Mass")); + Assert.AreEqual(-1.5f, SemanticValue(instance, "Offset")); + Assert.AreEqual(3, SemanticValue(instance, "Parts")); + Assert.AreEqual(4f, SemanticValue(instance, "Ballast")); + Assert.AreEqual(new System.Numerics.Vector3(1f), type.GetProperty("Scale")!.GetValue(instance)); + Assert.AreEqual(Activator.CreateInstance(type.GetProperty("Cell")!.PropertyType, 2, 2), type.GetProperty("Cell")!.GetValue(instance)); + Assert.AreEqual(new Runtime.ColorRgba(0.5f, 0.5f, 0.5f, 0.5f), type.GetProperty("Tint")!.GetValue(instance)); + + static void AddDefaulted(SchemaClass owner, string name, Models.Types.BaseType memberType, double value) + { + SchemaMember member = owner.AddMember(name.As())!; + member.SetType(memberType); + member.DefaultValue = new NumberDefault { Value = value }; + } + + // A semantic member's value, read through the Value property of the type it holds. + static object SemanticValue(object owner, string property) + { + object held = owner.GetType().GetProperty(property)!.GetValue(owner)!; + return held.GetType().GetProperty("Value")!.GetValue(held)!; + } + } + /// /// A class that travels as bytes, holding one that does the same - which is what the promise /// permits and what the C# side could not represent before. diff --git a/Schema/Generation/CSharpCodeGenerator.cs b/Schema/Generation/CSharpCodeGenerator.cs index 7edfe7b..1a862af 100644 --- a/Schema/Generation/CSharpCodeGenerator.cs +++ b/Schema/Generation/CSharpCodeGenerator.cs @@ -754,26 +754,72 @@ private static string ObjectInitialiserFor(Models.Types.Object objectType) => /// /// Gets the initialiser for a member's default, or null when it has none that fits. /// + private static string? DefaultInitialiserFor(SchemaMember member) => + member.DefaultValue is { } value && DefaultLiteralFor(value, member.Type) is string literal + ? $" = {literal};" + : null; + + /// + /// Writes a default as an expression of the given type, or null when it has none that fits. + /// /// + /// Validation reads a default through a semantic type to its representation, and lets a single + /// number stand for every component of a vector, so both are written here the way the C++ + /// generator writes them: a semantic type through its explicit conversion, and a vector with the + /// number in each component. + /// /// A default of a kind the member cannot hold is a validation error, and generation is refused /// for a schema that has one - so the mismatched cases here are only reachable by calling this /// generator directly on a schema that was never validated. They fall through to the type's /// own initialiser rather than emitting source that does not compile. + /// /// - private static string? DefaultInitialiserFor(SchemaMember member) => (member.DefaultValue, member.Type) switch + private static string? DefaultLiteralFor(MemberDefault value, BaseType type) => (value, type) switch { - (NumberDefault number, Int) => $" = {(long)number.Value};", - (NumberDefault number, Long) => $" = {(long)number.Value}L;", - (NumberDefault number, Float) when !double.IsFinite(number.Value) => $" = float.{NonFinite(number.Value)};", - (NumberDefault number, Double) when !double.IsFinite(number.Value) => $" = double.{NonFinite(number.Value)};", - (NumberDefault number, Float) => $" = {number.Value.ToString("R", CultureInfo.InvariantCulture)}f;", - (NumberDefault number, Double) => $" = {Literal(number.Value)};", - (BooleanDefault boolean, Bool) => $" = {(boolean.Value ? "true" : "false")};", - (TextDefault text, Models.Types.String) => $" = {Quote(text.Value)};", - (TextDefault text, Models.Types.Enum enumType) => $" = {CSharpKeywords.Identifier(enumType.EnumName)}.{CSharpKeywords.Identifier(text.Value)};", + (NumberDefault number, Int) => $"{(long)number.Value}", + (NumberDefault number, Long) => $"{(long)number.Value}L", + (NumberDefault number, Float) when !double.IsFinite(number.Value) => $"float.{NonFinite(number.Value)}", + (NumberDefault number, Double) when !double.IsFinite(number.Value) => $"double.{NonFinite(number.Value)}", + (NumberDefault number, Float) => $"{number.Value.ToString("R", CultureInfo.InvariantCulture)}f", + (NumberDefault number, Double) => Literal(number.Value), + (BooleanDefault boolean, Bool) => boolean.Value ? "true" : "false", + (TextDefault text, Models.Types.String) => Quote(text.Value), + (TextDefault text, Models.Types.Enum enumType) => $"{CSharpKeywords.Identifier(enumType.EnumName)}.{CSharpKeywords.Identifier(text.Value)}", + + // The colours before their vector bases, as in MapType: ColorRGB derives from Vector3. + (NumberDefault, ColorRGB or ColorRGBA) => ComponentsLiteral(value, type, new Float()), + (NumberDefault, Vector vector) => ComponentsLiteral(value, vector, vector.ElementType), + + // A representation that is itself a semantic type is a refinement chain that never reached + // anything real, which validation reports; following it again would have no bottom. + (_, Semantic { Declaration: SchemaSemanticType declaration } semantic) + when declaration.Representation() is not Semantic + && DefaultLiteralFor(value, declaration.Representation()) is string represented + => $"({MapType(semantic)})({represented})", + _ => null, }; + /// + /// Writes a vector or colour default: one number, in every component. + /// + private static string? ComponentsLiteral(MemberDefault value, BaseType type, BaseType elementType) + { + if (DefaultLiteralFor(value, elementType) is not string component) + { + return null; + } + + int count = type switch + { + Vector2 => 2, + Vector3 => 3, + _ => 4, + }; + + return $"new {MapType(type)}({string.Join(", ", Enumerable.Repeat(component, count))})"; + } + /// /// Names one of the two values a floating-point number can hold that has no literal. ///