diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb6ac7d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: + - main + paths: + - 'Alchemy.SourceGenerator/**' + - 'Alchemy.SourceGenerator.Tests/**' + - '.github/workflows/ci.yml' + pull_request: + paths: + - 'Alchemy.SourceGenerator/**' + - 'Alchemy.SourceGenerator.Tests/**' + - '.github/workflows/ci.yml' + workflow_dispatch: + +jobs: + source-generator-tests: + name: Source generator tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln + + - name: Build + run: > + dotnet build Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln + --configuration Release + --no-restore + + - name: Test + run: > + dotnet run + --project Alchemy.SourceGenerator.Tests/Alchemy.SourceGenerator.Tests.csproj + --configuration Release + --no-build + -- + --treenode-filter "/*/Alchemy.SourceGenerator.Tests/*/*" diff --git a/Alchemy.SourceGenerator.Tests/.gitignore b/Alchemy.SourceGenerator.Tests/.gitignore new file mode 100644 index 0000000..62d1e79 --- /dev/null +++ b/Alchemy.SourceGenerator.Tests/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +TestResults/ diff --git a/Alchemy.SourceGenerator.Tests/Alchemy.SourceGenerator.Tests.csproj b/Alchemy.SourceGenerator.Tests/Alchemy.SourceGenerator.Tests.csproj new file mode 100644 index 0000000..215b5a9 --- /dev/null +++ b/Alchemy.SourceGenerator.Tests/Alchemy.SourceGenerator.Tests.csproj @@ -0,0 +1,30 @@ + + + + + Exe + net10.0 + latest + enable + enable + false + Alchemy.SourceGenerator.Tests + + $(NoWarn);NU1608;NU1701 + + + + + + + + + + + + + diff --git a/Alchemy.SourceGenerator.Tests/DiagnosticTests.cs b/Alchemy.SourceGenerator.Tests/DiagnosticTests.cs new file mode 100644 index 0000000..2e418a7 --- /dev/null +++ b/Alchemy.SourceGenerator.Tests/DiagnosticTests.cs @@ -0,0 +1,198 @@ +using Microsoft.CodeAnalysis; + +namespace Alchemy.SourceGenerator.Tests; + +/// +/// The three diagnostics declared in . +/// +public class DiagnosticTests +{ + [Test] + public async Task ALCHEMY001_is_reported_for_a_non_partial_type() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public class NotPartial + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.HasDiagnostic("ALCHEMY001")).IsTrue(); + + var diagnostic = result.GeneratorDiagnostics.Single(d => d.Id == "ALCHEMY001"); + await Assert.That(diagnostic.Severity).IsEqualTo(DiagnosticSeverity.Error); + await Assert.That(diagnostic.GetMessage()).Contains("NotPartial"); + } + + [Test] + public async Task ALCHEMY001_suppresses_generation_for_that_type() + { + var result = GeneratorUtils.Run(""" + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public class NotPartial { } + } + """); + + await Assert.That(result.GeneratedSources).IsEmpty(); + } + + [Test] + public async Task ALCHEMY002_is_reported_for_a_nested_type() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + public partial class Outer + { + [AlchemySerialize] + public partial class Inner + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + } + """); + + await Assert.That(result.HasDiagnostic("ALCHEMY002")).IsTrue(); + await Assert.That(result.GeneratedSources).IsEmpty(); + + var diagnostic = result.GeneratorDiagnostics.Single(d => d.Id == "ALCHEMY002"); + await Assert.That(diagnostic.Severity).IsEqualTo(DiagnosticSeverity.Error); + await Assert.That(diagnostic.GetMessage()).Contains("Inner"); + } + + [Test] + public async Task ALCHEMY003_warns_when_the_field_is_not_marked_NonSerialized() + { + // Without [NonSerialized] Unity serializes the field itself as well as the + // JSON payload, so the value is stored twice and can diverge. + var result = GeneratorUtils.Run(""" + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Warned + { + [AlchemySerializeField] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.HasDiagnostic("ALCHEMY003")).IsTrue(); + + var diagnostic = result.GeneratorDiagnostics.Single(d => d.Id == "ALCHEMY003"); + await Assert.That(diagnostic.Severity).IsEqualTo(DiagnosticSeverity.Warning); + await Assert.That(diagnostic.GetMessage()).Contains("map"); + } + + [Test] + public async Task ALCHEMY003_still_generates_the_code() + { + // It is a warning, not an error — generation must continue. + var result = GeneratorUtils.Run(""" + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Warned + { + [AlchemySerializeField] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.GeneratedSources.Length).IsEqualTo(1); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task No_diagnostic_when_NonSerialized_is_present() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Clean + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.GeneratorDiagnostics).IsEmpty(); + } + + [Test] + public async Task Namespace_qualified_NonSerialized_is_recognised() + { + // The bare spelling is covered by No_diagnostic_when_NonSerialized_is_present. + var result = GeneratorUtils.Run(""" + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Spelled + { + [AlchemySerializeField, System.NonSerialized] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.HasDiagnostic("ALCHEMY003")).IsFalse(); + } + + [Test] + public async Task The_generator_never_crashes_on_well_formed_input() + { + // A generator crash surfaces as the catch-all "AlchemySerializeGeneratorError". + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Fine + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.HasDiagnostic("AlchemySerializeGeneratorError")).IsFalse(); + } +} diff --git a/Alchemy.SourceGenerator.Tests/GenerationTests.cs b/Alchemy.SourceGenerator.Tests/GenerationTests.cs new file mode 100644 index 0000000..219f254 --- /dev/null +++ b/Alchemy.SourceGenerator.Tests/GenerationTests.cs @@ -0,0 +1,259 @@ +namespace Alchemy.SourceGenerator.Tests; + +/// +/// Core generation behaviour: what the generator emits for well-formed input, +/// and that the emitted code actually compiles. +/// +public class GenerationTests +{ + const string Simple = """ + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Sample + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """; + + [Test] + public async Task Emits_exactly_one_source_file_per_attributed_type() + { + var result = GeneratorUtils.Run(Simple); + await Assert.That(result.GeneratedSources.Length).IsEqualTo(1); + } + + [Test] + public async Task Generated_code_compiles_cleanly() + { + var result = GeneratorUtils.Run(Simple); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Hint_name_is_the_fully_qualified_type_name() + { + var result = GeneratorUtils.Run(Simple); + await Assert.That(result.GeneratedSources[0].HintName) + .IsEqualTo("Demo.Sample.AlchemySerializeGenerator.g.cs"); + } + + [Test] + public async Task Implements_ISerializationCallbackReceiver_explicitly() + { + var text = GeneratorUtils.Run(Simple).AllGeneratedText; + + await Assert.That(text).Contains("partial class Sample : global::UnityEngine.ISerializationCallbackReceiver"); + await Assert.That(text).Contains("void global::UnityEngine.ISerializationCallbackReceiver.OnBeforeSerialize()"); + await Assert.That(text).Contains("void global::UnityEngine.ISerializationCallbackReceiver.OnAfterDeserialize()"); + } + + [Test] + public async Task Round_trips_each_field_through_SerializationHelper() + { + var text = GeneratorUtils.Run(Simple).AllGeneratedText; + + await Assert.That(text).Contains("SerializationHelper.ToJson(this.map"); + await Assert.That(text).Contains("SerializationHelper.FromJson<"); + await Assert.That(text).Contains(".map.isCreated = true;"); + } + + [Test] + public async Task Clears_the_UnityObject_reference_table_before_each_serialize() + { + // The reference table is index-based; failing to clear it would make indices + // drift on every re-serialize. + var text = GeneratorUtils.Run(Simple).AllGeneratedText; + await Assert.That(text).Contains("UnityObjectReferences.Clear();"); + } + + [Test] + public async Task Wraps_every_field_in_try_catch_so_one_bad_field_cannot_abort_the_rest() + { + var text = GeneratorUtils.Run(Simple).AllGeneratedText; + await Assert.That(text).Contains("catch (global::System.Exception ex)"); + await Assert.That(text).Contains("global::UnityEngine.Debug.LogException(ex);"); + } + + [Test] + public async Task Backing_store_is_hidden_from_the_inspector_by_default() + { + var text = GeneratorUtils.Run(Simple).AllGeneratedText; + await Assert.That(text).Contains("[global::UnityEngine.HideInInspector, global::UnityEngine.SerializeField]"); + } + + [Test] + public async Task ShowAlchemySerializationData_replaces_HideInInspector_with_a_visible_readonly_field() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize, ShowAlchemySerializationData] + public partial class Shown + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + var text = result.AllGeneratedText; + await Assert.That(text).Contains("global::Alchemy.Inspector.LabelText(\"Alchemy Serialization Data (Demo.Shown)\")"); + await Assert.That(text).Contains("global::Alchemy.Inspector.ReadOnly"); + await Assert.That(text).DoesNotContain("[global::UnityEngine.HideInInspector, global::UnityEngine.SerializeField] private AlchemySerializationData"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Multiple_fields_each_get_their_own_serialization_slot() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Many + { + [AlchemySerializeField, NonSerialized] public Dictionary a = new(); + [AlchemySerializeField, NonSerialized] public HashSet b = new(); + [AlchemySerializeField, NonSerialized] public (int, int) c; + } + } + """); + + var text = result.AllGeneratedText; + await Assert.That(text).Contains("public Item a = new();"); + await Assert.That(text).Contains("public Item b = new();"); + await Assert.That(text).Contains("public Item c = new();"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Multiple_declarators_on_one_field_line_are_all_captured() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Decl + { + [AlchemySerializeField, NonSerialized] + public Dictionary x = new(), y = new(); + } + } + """); + + var text = result.AllGeneratedText; + await Assert.That(text).Contains("public Item x = new();"); + await Assert.That(text).Contains("public Item y = new();"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Fields_without_the_attribute_are_ignored() + { + var text = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Mixed + { + [AlchemySerializeField, NonSerialized] public Dictionary tracked = new(); + public int untracked; + } + } + """).AllGeneratedText; + + await Assert.That(text).Contains("public Item tracked = new();"); + await Assert.That(text).DoesNotContain("untracked"); + } + + [Test] + public async Task A_type_with_no_attributed_fields_still_produces_valid_code() + { + var result = GeneratorUtils.Run(""" + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Empty { } + } + """); + + await Assert.That(result.GeneratedSources.Length).IsEqualTo(1); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Source_without_the_attribute_generates_nothing() + { + var result = GeneratorUtils.Run(""" + namespace Demo + { + public partial class Untouched + { + public int value; + } + } + """); + + await Assert.That(result.GeneratedSources).IsEmpty(); + await Assert.That(result.GeneratorDiagnostics).IsEmpty(); + } + + [Test] + public async Task Private_and_protected_fields_are_supported() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Access + { + [AlchemySerializeField, NonSerialized] private Dictionary priv = new(); + [AlchemySerializeField, NonSerialized] protected HashSet prot = new(); + } + } + """); + + var text = result.AllGeneratedText; + await Assert.That(text).Contains("public Item priv = new();"); + await Assert.That(text).Contains("public Item prot = new();"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Generation_is_deterministic_across_runs() + { + var first = GeneratorUtils.Run(Simple).AllGeneratedText; + var second = GeneratorUtils.Run(Simple).AllGeneratedText; + await Assert.That(first).IsEqualTo(second); + } + +} diff --git a/Alchemy.SourceGenerator.Tests/GeneratorUtils.cs b/Alchemy.SourceGenerator.Tests/GeneratorUtils.cs new file mode 100644 index 0000000..e6c920d --- /dev/null +++ b/Alchemy.SourceGenerator.Tests/GeneratorUtils.cs @@ -0,0 +1,177 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Alchemy.SourceGenerator.Tests; + +/// +/// The outcome of running over a piece of source text. +/// +public sealed record GeneratorResult( + ImmutableArray GeneratorDiagnostics, + ImmutableArray GeneratedSources, + Compilation OutputCompilation) +{ + /// Diagnostics the generator itself reported (ALCHEMY001-003, crash reports). + public IEnumerable Errors => + GeneratorDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Error); + + public IEnumerable Warnings => + GeneratorDiagnostics.Where(d => d.Severity == DiagnosticSeverity.Warning); + + public bool HasDiagnostic(string id) => GeneratorDiagnostics.Any(d => d.Id == id); + + /// All generated code concatenated, convenient for substring assertions. + public string AllGeneratedText => + string.Join("\n", GeneratedSources.Select(s => s.Text)); + + /// + /// Compile errors produced by the *final* compilation (original source + generated code). + /// This is what proves the generator emitted valid C#. + /// + public ImmutableArray CompilationErrors => + OutputCompilation.GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToImmutableArray(); + + public string DescribeCompilationErrors() => + CompilationErrors.Length == 0 + ? "" + : string.Join("\n", CompilationErrors.Select(d => $"{d.Id}: {d.GetMessage()} @ {d.Location.GetLineSpan()}")); +} + +public sealed record GeneratedSource(string HintName, string Text); + +/// +/// Compiles source text and runs the Alchemy source generator over it. +/// +public static class GeneratorUtils +{ + static readonly ImmutableArray FrameworkReferences = LoadFrameworkReferences(); + + static ImmutableArray LoadFrameworkReferences() + { + // Reference every assembly in the running framework so the test source can use + // anything from the BCL without pulling in a reference-assembly package. + var tpa = (string?)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") ?? ""; + return tpa.Split(Path.PathSeparator) + .Where(p => p.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(p)) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)) + .ToImmutableArray(); + } + + /// + /// Minimal stand-ins for the Unity and Alchemy types the generated code references. + /// Prepended to every test compilation so that "does the generated code compile?" + /// is a meaningful question without a Unity install. + /// + public const string Stubs = """ + namespace UnityEngine + { + public class Object { } + public interface ISerializationCallbackReceiver + { + void OnBeforeSerialize(); + void OnAfterDeserialize(); + } + [System.AttributeUsage(System.AttributeTargets.Field)] + public sealed class SerializeField : System.Attribute { } + [System.AttributeUsage(System.AttributeTargets.Field)] + public sealed class HideInInspector : System.Attribute { } + [System.AttributeUsage(System.AttributeTargets.Field)] + public sealed class TextArea : System.Attribute + { + public TextArea() { } + public TextArea(int minLines, int maxLines) { } + } + public static class Debug + { + public static void LogException(System.Exception ex) { } + } + } + + namespace Alchemy.Serialization + { + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)] + public sealed class AlchemySerializeAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Field)] + public sealed class AlchemySerializeFieldAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Class | System.AttributeTargets.Struct)] + public sealed class ShowAlchemySerializationDataAttribute : System.Attribute { } + + public interface IAlchemySerializationCallbackReceiver + { + void OnBeforeSerialize(); + void OnAfterDeserialize(); + } + } + + namespace Alchemy.Serialization.Internal + { + public static class SerializationHelper + { + public static string ToJson(T target, System.Collections.Generic.IList unityObjectReferences) => ""; + public static T FromJson(string json, System.Collections.Generic.IList unityObjectReferences) => default!; + } + } + + namespace Alchemy.Inspector + { + [System.AttributeUsage(System.AttributeTargets.All)] + public sealed class LabelTextAttribute : System.Attribute + { + public LabelTextAttribute(string text) { } + } + [System.AttributeUsage(System.AttributeTargets.All)] + public sealed class ReadOnlyAttribute : System.Attribute { } + } + """; + + /// Runs the generator over , with the Unity/Alchemy stubs included. + public static GeneratorResult Run(params string[] sources) => + RunCore(new AlchemySerializeGenerator(), includeStubs: true, sources); + + /// Runs the generator over only, without the stub types. + public static GeneratorResult RunWithoutStubs(params string[] sources) => + RunCore(new AlchemySerializeGenerator(), includeStubs: false, sources); + + static GeneratorResult RunCore(ISourceGenerator generator, bool includeStubs, string[] sources) + { + var parseOptions = new CSharpParseOptions(LanguageVersion.Latest); + + var trees = new List(); + if (includeStubs) trees.Add(CSharpSyntaxTree.ParseText(Stubs, parseOptions, path: "Stubs.cs")); + for (var i = 0; i < sources.Length; i++) + { + trees.Add(CSharpSyntaxTree.ParseText(sources[i], parseOptions, path: $"Source{i}.cs")); + } + + var compilation = CSharpCompilation.Create( + assemblyName: "AlchemyGeneratorTestAssembly", + syntaxTrees: trees, + references: FrameworkReferences, + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + // AlchemySerializeGenerator implements the v1 ISourceGenerator interface, so it is + // passed straight through (AsSourceGenerator() is the IIncrementalGenerator adapter). + var driver = CSharpGeneratorDriver.Create( + generators: new[] { generator }, + additionalTexts: null, + parseOptions: parseOptions, + optionsProvider: null); + + driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation( + compilation, out var outputCompilation, out var diagnostics); + + var runResult = driver.GetRunResult(); + + var generated = runResult.Results + .SelectMany(r => r.GeneratedSources) + .Select(s => new GeneratedSource(s.HintName, s.SourceText.ToString())) + .ToImmutableArray(); + + return new GeneratorResult(diagnostics, generated, outputCompilation); + } +} diff --git a/Alchemy.SourceGenerator.Tests/GenericTypeTests.cs b/Alchemy.SourceGenerator.Tests/GenericTypeTests.cs new file mode 100644 index 0000000..fd01305 --- /dev/null +++ b/Alchemy.SourceGenerator.Tests/GenericTypeTests.cs @@ -0,0 +1,166 @@ +namespace Alchemy.SourceGenerator.Tests; + +/// +/// Generic targets, and the naming contract that ties the generator to the editor. +/// +public class GenericTypeTests +{ + /// + /// Reproduces the backing-field name the editor computes at runtime in + /// InspectorHelper.CreateMemberElement: + /// + /// var declaredType = fieldInfo.DeclaringType; + /// if (declaredType.IsConstructedGenericType) declaredType = declaredType.GetGenericTypeDefinition(); + /// var dataName = "__alchemySerializationData_" + declaredType.FullName.Replace("`", "").Replace(".", "_"); + /// + /// The generator builds the same name from Roslyn symbols by a completely different + /// route, so the two must be pinned together or the inspector silently fails to find + /// the serialized data. + /// + static string ExpectedNameFromEditorSide(string reflectionFullName) => + "__alchemySerializationData_" + reflectionFullName.Replace("`", "").Replace(".", "_"); + + [Test] + public async Task Single_type_parameter_generates_and_compiles() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo.Deep + { + [AlchemySerialize] + public partial class Gen + { + [AlchemySerializeField, NonSerialized] + public List items = new(); + } + } + """); + + await Assert.That(result.AllGeneratedText).Contains("partial class Gen"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Backing_field_name_matches_what_the_editor_looks_up_for_one_type_parameter() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo.Deep + { + [AlchemySerialize] + public partial class Gen + { + [AlchemySerializeField, NonSerialized] + public List items = new(); + } + } + """); + + // typeof(Gen<>).FullName == "Demo.Deep.Gen`1" + var expected = ExpectedNameFromEditorSide("Demo.Deep.Gen`1"); + await Assert.That(expected).IsEqualTo("__alchemySerializationData_Demo_Deep_Gen1"); + await Assert.That(result.AllGeneratedText).Contains(expected); + } + + [Test] + public async Task Backing_field_name_matches_what_the_editor_looks_up_for_two_type_parameters() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Pair + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + // typeof(Pair<,>).FullName == "Demo.Pair`2" + var expected = ExpectedNameFromEditorSide("Demo.Pair`2"); + await Assert.That(expected).IsEqualTo("__alchemySerializationData_Demo_Pair2"); + await Assert.That(result.AllGeneratedText).Contains(expected); + } + + [Test] + public async Task Backing_field_name_matches_for_a_non_generic_type() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Plain + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + var expected = ExpectedNameFromEditorSide("Demo.Plain"); + await Assert.That(expected).IsEqualTo("__alchemySerializationData_Demo_Plain"); + await Assert.That(result.AllGeneratedText).Contains(expected); + } + + [Test] + public async Task Type_parameters_are_carried_onto_the_partial_declaration() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Pair + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.AllGeneratedText).Contains("partial class Pair"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Generic_type_with_a_constraint_compiles() + { + // The generated partial deliberately omits the where-clause, which is legal C# + // as long as at least one declaration states it. + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Constrained where T : class, new() + { + [AlchemySerializeField, NonSerialized] + public List items = new(); + } + } + """); + + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } +} diff --git a/Alchemy.SourceGenerator.Tests/InheritanceTests.cs b/Alchemy.SourceGenerator.Tests/InheritanceTests.cs new file mode 100644 index 0000000..a4ce753 --- /dev/null +++ b/Alchemy.SourceGenerator.Tests/InheritanceTests.cs @@ -0,0 +1,175 @@ +namespace Alchemy.SourceGenerator.Tests; + +/// +/// Inheritance chaining. When a base class is also [AlchemySerialize], the derived +/// class must hide the base helpers with new and forward to them with +/// base. — otherwise the base class's fields silently stop round-tripping. +/// +public class InheritanceTests +{ + const string BaseAndDerived = """ + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class BaseType + { + [AlchemySerializeField, NonSerialized] + public Dictionary fromBase = new(); + } + + [AlchemySerialize] + public partial class DerivedType : BaseType + { + [AlchemySerializeField, NonSerialized] + public HashSet fromDerived = new(); + } + } + """; + + [Test] + public async Task Both_types_are_generated() + { + var result = GeneratorUtils.Run(BaseAndDerived); + await Assert.That(result.GeneratedSources.Length).IsEqualTo(2); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Derived_type_forwards_to_the_base_implementation() + { + var derived = GeneratorUtils.Run(BaseAndDerived) + .GeneratedSources.Single(s => s.HintName.Contains("DerivedType")).Text; + + await Assert.That(derived).Contains("base.__AlchemyOnBeforeSerialize();"); + await Assert.That(derived).Contains("base.__AlchemyOnAfterDeserialize();"); + } + + [Test] + public async Task Derived_type_hides_the_base_helpers_with_new() + { + var derived = GeneratorUtils.Run(BaseAndDerived) + .GeneratedSources.Single(s => s.HintName.Contains("DerivedType")).Text; + + await Assert.That(derived).Contains("protected new void __AlchemyOnBeforeSerialize()"); + await Assert.That(derived).Contains("protected new void __AlchemyOnAfterDeserialize()"); + } + + [Test] + public async Task Base_type_does_not_forward_to_anything() + { + var baseText = GeneratorUtils.Run(BaseAndDerived) + .GeneratedSources.Single(s => s.HintName.Contains("BaseType")).Text; + + await Assert.That(baseText).DoesNotContain("base.__Alchemy"); + await Assert.That(baseText).DoesNotContain("protected new void"); + } + + [Test] + public async Task Each_level_keeps_its_own_backing_store() + { + // Base and derived must not share one AlchemySerializationData instance, + // or clearing the reference table in one would corrupt the other. + var result = GeneratorUtils.Run(BaseAndDerived); + + await Assert.That(result.AllGeneratedText).Contains("__alchemySerializationData_Demo_BaseType"); + await Assert.That(result.AllGeneratedText).Contains("__alchemySerializationData_Demo_DerivedType"); + } + + [Test] + public async Task A_derived_type_whose_base_is_not_attributed_does_not_forward() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + public partial class PlainBase { } + + [AlchemySerialize] + public partial class OnlyDerived : PlainBase + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.AllGeneratedText).DoesNotContain("base.__Alchemy"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Attribution_is_detected_through_an_unattributed_intermediate_class() + { + // Base -> Middle (not attributed) -> Leaf. The generator walks the whole + // base chain, so Leaf must still chain to Base's implementation. + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class Root + { + [AlchemySerializeField, NonSerialized] + public Dictionary a = new(); + } + + public partial class Middle : Root { } + + [AlchemySerialize] + public partial class Leaf : Middle + { + [AlchemySerializeField, NonSerialized] + public HashSet b = new(); + } + } + """); + + var leaf = result.GeneratedSources.Single(s => s.HintName.Contains("Leaf")).Text; + await Assert.That(leaf).Contains("base.__AlchemyOnBeforeSerialize();"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Three_level_chain_compiles() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Demo + { + [AlchemySerialize] + public partial class L1 + { + [AlchemySerializeField, NonSerialized] public Dictionary a = new(); + } + + [AlchemySerialize] + public partial class L2 : L1 + { + [AlchemySerializeField, NonSerialized] public HashSet b = new(); + } + + [AlchemySerialize] + public partial class L3 : L2 + { + [AlchemySerializeField, NonSerialized] public System.Collections.Generic.List c = new(); + } + } + """); + + await Assert.That(result.GeneratedSources.Length).IsEqualTo(3); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } +} diff --git a/Alchemy.SourceGenerator.Tests/NamespaceTests.cs b/Alchemy.SourceGenerator.Tests/NamespaceTests.cs new file mode 100644 index 0000000..e44e6d2 --- /dev/null +++ b/Alchemy.SourceGenerator.Tests/NamespaceTests.cs @@ -0,0 +1,137 @@ +namespace Alchemy.SourceGenerator.Tests; + +/// +/// Namespace emission — the generator reconstructs the containing namespace by hand, +/// so every namespace shape needs covering. +/// +public class NamespaceTests +{ + [Test] + public async Task Block_scoped_namespace_is_reproduced() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace Outer.Inner.Deep + { + [AlchemySerialize] + public partial class Deeply + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.AllGeneratedText).Contains("namespace Outer.Inner.Deep {"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task File_scoped_namespace_is_supported() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace FileScoped; + + [AlchemySerialize] + public partial class Sample + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + """); + + await Assert.That(result.AllGeneratedText).Contains("namespace FileScoped {"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Global_namespace_emits_no_namespace_block() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + [AlchemySerialize] + public partial class NoNamespace + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + """); + + await Assert.That(result.AllGeneratedText).DoesNotContain("namespace "); + await Assert.That(result.GeneratedSources[0].HintName) + .IsEqualTo("NoNamespace.AlchemySerializeGenerator.g.cs"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Two_same_named_types_in_different_namespaces_do_not_collide() + { + // Both produce a type called "Sample"; the hint name must disambiguate them + // or AddSource throws on the duplicate. + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace First + { + [AlchemySerialize] + public partial class Sample + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + + namespace Second + { + [AlchemySerialize] + public partial class Sample + { + [AlchemySerializeField, NonSerialized] + public HashSet set = new(); + } + } + """); + + await Assert.That(result.GeneratedSources.Length).IsEqualTo(2); + await Assert.That(result.HasDiagnostic("AlchemySerializeGeneratorError")).IsFalse(); + + var hintNames = result.GeneratedSources.Select(s => s.HintName).OrderBy(x => x).ToArray(); + await Assert.That(hintNames[0]).IsEqualTo("First.Sample.AlchemySerializeGenerator.g.cs"); + await Assert.That(hintNames[1]).IsEqualTo("Second.Sample.AlchemySerializeGenerator.g.cs"); + await Assert.That(result.DescribeCompilationErrors()).IsEqualTo(""); + } + + [Test] + public async Task Backing_field_name_is_namespace_qualified_so_it_cannot_clash() + { + var result = GeneratorUtils.Run(""" + using System; + using System.Collections.Generic; + using Alchemy.Serialization; + + namespace My.Game + { + [AlchemySerialize] + public partial class Player + { + [AlchemySerializeField, NonSerialized] + public Dictionary map = new(); + } + } + """); + + await Assert.That(result.AllGeneratedText).Contains("__alchemySerializationData_My_Game_Player"); + } +} diff --git a/Alchemy.SourceGenerator.Tests/README.md b/Alchemy.SourceGenerator.Tests/README.md new file mode 100644 index 0000000..ad1cef2 --- /dev/null +++ b/Alchemy.SourceGenerator.Tests/README.md @@ -0,0 +1,17 @@ +# Alchemy.SourceGenerator.Tests + +Tests for `AlchemySerializeGenerator`, using [TUnit](https://tunit.dev/). + +```bash +cd Alchemy.SourceGenerator.Tests +dotnet run +``` + +TUnit runs on `Microsoft.Testing.Platform`, so this project is an executable. Use `dotnet run`, +not `dotnet test`. + +```bash +dotnet run --treenode-filter "/*/*/GenerationTests/*" # a single class +dotnet run --coverage +dotnet run --report-trx +``` diff --git a/Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln b/Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln index 5e0d4e4..983c9f1 100644 --- a/Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln +++ b/Alchemy.SourceGenerator/Alchemy.SourceGenerator.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 25.0.1706.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alchemy.SourceGenerator", "Alchemy.SourceGenerator.csproj", "{073160C2-08C9-4905-A4D0-7897AADB4E6C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Alchemy.SourceGenerator.Tests", "..\Alchemy.SourceGenerator.Tests\Alchemy.SourceGenerator.Tests.csproj", "{74109E75-6475-4613-BFC1-2296669ADC59}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -15,6 +17,10 @@ Global {073160C2-08C9-4905-A4D0-7897AADB4E6C}.Debug|Any CPU.Build.0 = Debug|Any CPU {073160C2-08C9-4905-A4D0-7897AADB4E6C}.Release|Any CPU.ActiveCfg = Release|Any CPU {073160C2-08C9-4905-A4D0-7897AADB4E6C}.Release|Any CPU.Build.0 = Release|Any CPU + {74109E75-6475-4613-BFC1-2296669ADC59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74109E75-6475-4613-BFC1-2296669ADC59}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74109E75-6475-4613-BFC1-2296669ADC59}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74109E75-6475-4613-BFC1-2296669ADC59}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Alchemy.SourceGenerator/README.md b/Alchemy.SourceGenerator/README.md new file mode 100644 index 0000000..31854c5 --- /dev/null +++ b/Alchemy.SourceGenerator/README.md @@ -0,0 +1,12 @@ +# Alchemy.SourceGenerator + +Source generator for Alchemy. + +# How to update the shipped DLL + +Unity consumes the compiled generator at +`Alchemy/Assets/Alchemy/Generator/Alchemy.SourceGenerator.dll`. After changing the generator, rebuild it with: + +```bash +dotnet run scripts/generator.cs +``` diff --git a/scripts/generator.cs b/scripts/generator.cs new file mode 100644 index 0000000..7437ab6 --- /dev/null +++ b/scripts/generator.cs @@ -0,0 +1,103 @@ +#!/usr/bin/env dotnet +// +// Rebuilds Alchemy.SourceGenerator and refreshes the DLL shipped inside the Unity package. +// +// dotnet run scripts/generator.cs +// +// Unity loads the compiled generator from Alchemy/Assets/Alchemy/Generator/, so that copy has to +// be rebuilt and committed whenever the generator source changes. + +using System.Diagnostics; +using System.Runtime.CompilerServices; + +if (args.Length > 0) +{ + Console.Error.WriteLine($"error: unexpected argument '{args[0]}'"); + Console.Error.WriteLine("usage: dotnet run scripts/generator.cs"); + return 2; +} + +var repoRoot = FindRepositoryRoot(); +var projectPath = Path.Combine(repoRoot, "Alchemy.SourceGenerator", "Alchemy.SourceGenerator.csproj"); +var shippedDll = Path.Combine(repoRoot, "Alchemy", "Assets", "Alchemy", "Generator", "Alchemy.SourceGenerator.dll"); + +if (!File.Exists(shippedDll)) +{ + Console.Error.WriteLine($"error: shipped DLL not found at {Relative(shippedDll)}"); + return 1; +} + +var buildDir = Directory.CreateTempSubdirectory("alchemy-generator-").FullName; + +try +{ + Console.WriteLine("Building Alchemy.SourceGenerator..."); + + var exitCode = Run("dotnet", [ + "build", projectPath, + "--configuration", "Release", + "--output", buildDir, + "--nologo", + "--verbosity", "quiet" + ]); + + if (exitCode != 0) + { + Console.Error.WriteLine("error: build failed"); + return 1; + } + + var builtDll = Path.Combine(buildDir, "Alchemy.SourceGenerator.dll"); + + if (ContentEquals(builtDll, shippedDll)) + { + Console.WriteLine($"Unchanged: {Relative(shippedDll)} is already current."); + return 0; + } + + File.Copy(builtDll, shippedDll, overwrite: true); + Console.WriteLine($"Updated: {Relative(shippedDll)}"); + return 0; +} +finally +{ + try { Directory.Delete(buildDir, recursive: true); } catch { /* best effort */ } +} + +static bool ContentEquals(string left, string right) => + File.ReadAllBytes(left).AsSpan().SequenceEqual(File.ReadAllBytes(right)); + +static int Run(string fileName, string[] arguments) +{ + var startInfo = new ProcessStartInfo(fileName) { UseShellExecute = false }; + foreach (var argument in arguments) startInfo.ArgumentList.Add(argument); + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException($"could not start {fileName}"); + + process.WaitForExit(); + return process.ExitCode; +} + +string Relative(string path) => Path.GetRelativePath(repoRoot, path).Replace('\\', '/'); + +// The script is compiled in place, so the source path locates the repository without +// depending on the working directory. Falls back to walking up from the caller's directory. +static string FindRepositoryRoot([CallerFilePath] string scriptPath = "") +{ + foreach (var start in new[] { Path.GetDirectoryName(scriptPath), Environment.CurrentDirectory }) + { + if (string.IsNullOrEmpty(start)) continue; + + for (var directory = new DirectoryInfo(start); directory != null; directory = directory.Parent) + { + if (Directory.Exists(Path.Combine(directory.FullName, "Alchemy.SourceGenerator")) && + Directory.Exists(Path.Combine(directory.FullName, "Alchemy"))) + { + return directory.FullName; + } + } + } + + throw new InvalidOperationException("could not locate the repository root"); +}