diff --git a/config.json b/config.json index bf902cb430..2bcc3385d8 100644 --- a/config.json +++ b/config.json @@ -857,7 +857,8 @@ ], "ignored": [ "docs", - "img" + "img", + "generators" ], "foregone": [ "lens-person", diff --git a/generators/CanonicalData.cs b/generators/CanonicalData.cs new file mode 100644 index 0000000000..367c04862a --- /dev/null +++ b/generators/CanonicalData.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using Newtonsoft.Json; + +namespace Generators +{ + public class CanonicalData + { + [Required] + public string Exercise { get; set; } + + [Required] + public string Version { get; set; } + + public string[] Comments { get; set; } + + [JsonConverter(typeof(CanonicalDataCasesJsonConverter))] + public CanonicalDataCase[] Cases { get; set; } + } +} \ No newline at end of file diff --git a/generators/CanonicalDataCase.cs b/generators/CanonicalDataCase.cs new file mode 100644 index 0000000000..7ece6aaef7 --- /dev/null +++ b/generators/CanonicalDataCase.cs @@ -0,0 +1,24 @@ +using Newtonsoft.Json; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Generators +{ + [JsonConverter(typeof(CanonicalDataCaseJsonConverter))] + public class CanonicalDataCase + { + [Required] + public string Description { get; set; } + + [Required] + public string Property { get; set; } + + public string[] Comments { get; set; } + + public object Input { get; set; } + + public object Expected { get; set; } + + public IDictionary Data { get; set; } + } +} \ No newline at end of file diff --git a/generators/CanonicalDataCaseJsonConverter.cs b/generators/CanonicalDataCaseJsonConverter.cs new file mode 100644 index 0000000000..7fa7b31600 --- /dev/null +++ b/generators/CanonicalDataCaseJsonConverter.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Generators +{ + public class CanonicalDataCaseJsonConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return typeof(CanonicalDataCase) == objectType; + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var jToken = JToken.ReadFrom(reader); + + var canonicalDataCase = new CanonicalDataCase(); + serializer.Populate(new JTokenReader(jToken), canonicalDataCase); + + canonicalDataCase.Data = jToken.ToObject>(); + + return canonicalDataCase; + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/generators/CanonicalDataCasesJsonConverter.cs b/generators/CanonicalDataCasesJsonConverter.cs new file mode 100644 index 0000000000..05b0fc7596 --- /dev/null +++ b/generators/CanonicalDataCasesJsonConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Linq; + +namespace Generators +{ + public class CanonicalDataCasesJsonConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return typeof(IEnumerable).GetTypeInfo().IsAssignableFrom(objectType); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var casesToken = JToken.ReadFrom(reader); + var caseTokens = casesToken.SelectTokens("$..*[?(@.property)]").ToArray(); + + return new JArray(caseTokens).ToObject(objectType); + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/generators/CanonicalDataParser.cs b/generators/CanonicalDataParser.cs new file mode 100644 index 0000000000..130d505368 --- /dev/null +++ b/generators/CanonicalDataParser.cs @@ -0,0 +1,28 @@ +using Newtonsoft.Json; +using System; +using System.ComponentModel.DataAnnotations; +using System.Net.Http; + +namespace Generators +{ + public static class CanonicalDataParser + { + private static readonly HttpClient httpClient = new HttpClient(); + + public static CanonicalData Parse(string exercise) + { + var canonicalDataJson = DownloadCanonicalDataJson(exercise); + var canonicalData = JsonConvert.DeserializeObject(canonicalDataJson); + + Validator.ValidateObject(canonicalData, new ValidationContext(canonicalData)); + + return canonicalData; + } + + private static string DownloadCanonicalDataJson(string exercise) + => httpClient.GetStringAsync(GetCanonicalDataUrl(exercise)).GetAwaiter().GetResult(); + + private static Uri GetCanonicalDataUrl(string exercise) + => new Uri($"https://raw.githubusercontent.com/exercism/x-common/master/exercises/{exercise}/canonical-data.json"); + } +} \ No newline at end of file diff --git a/generators/ExerciseCollection.cs b/generators/ExerciseCollection.cs new file mode 100644 index 0000000000..ded5636a85 --- /dev/null +++ b/generators/ExerciseCollection.cs @@ -0,0 +1,23 @@ +using Generators.Exercises; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace Generators +{ + public class ExerciseCollection : IEnumerable + { + private readonly IEnumerable generators = GetDefinedGenerators(); + + public IEnumerator GetEnumerator() => generators.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + private static IEnumerable GetDefinedGenerators() => + from type in Assembly.GetEntryAssembly().GetTypes() + where typeof(Exercise).IsAssignableFrom(type) && !type.GetTypeInfo().IsAbstract + select (Exercise)Activator.CreateInstance(type); + } +} \ No newline at end of file diff --git a/generators/Exercises/Exercise.cs b/generators/Exercises/Exercise.cs new file mode 100644 index 0000000000..6effa58d07 --- /dev/null +++ b/generators/Exercises/Exercise.cs @@ -0,0 +1,14 @@ +namespace Generators.Exercises +{ + public abstract class Exercise + { + protected Exercise(string name) + { + Name = name; + } + + public string Name { get; } + + public abstract TestClass CreateTestClass(CanonicalData canonicalData); + } +} \ No newline at end of file diff --git a/generators/Generators.csproj b/generators/Generators.csproj new file mode 100644 index 0000000000..efab961ac0 --- /dev/null +++ b/generators/Generators.csproj @@ -0,0 +1,12 @@ + + + Exe + netcoreapp1.1 + + + + + + + + \ No newline at end of file diff --git a/generators/Generators.csproj.user b/generators/Generators.csproj.user new file mode 100644 index 0000000000..baf24173ca --- /dev/null +++ b/generators/Generators.csproj.user @@ -0,0 +1,6 @@ + + + + false + + \ No newline at end of file diff --git a/generators/Generators.sln b/generators/Generators.sln new file mode 100644 index 0000000000..de1c8b2771 --- /dev/null +++ b/generators/Generators.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26228.4 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Generators", "Generators.csproj", "{F310316B-5E18-4E7F-A77D-D26AD8D92307}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {F310316B-5E18-4E7F-A77D-D26AD8D92307}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F310316B-5E18-4E7F-A77D-D26AD8D92307}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F310316B-5E18-4E7F-A77D-D26AD8D92307}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F310316B-5E18-4E7F-A77D-D26AD8D92307}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/generators/Program.cs b/generators/Program.cs new file mode 100644 index 0000000000..888eb7c6bc --- /dev/null +++ b/generators/Program.cs @@ -0,0 +1,59 @@ +using Generators.Exercises; +using System.IO; +using Humanizer; +using Serilog; + +namespace Generators +{ + public static class Program + { + public static void Main() + { + SetupLogger(); + GenerateAll(); + } + + private static void SetupLogger() + { + Log.Logger = new LoggerConfiguration() + .WriteTo.LiterateConsole() + .CreateLogger(); + } + + private static void GenerateAll() + { + Log.Information("Start generating tests..."); + + foreach (var exercise in new ExerciseCollection()) + TestFileGenerator.Generate(exercise); + + Log.Information("Finished generating tests for all supported exercises."); + } + } + + public static class TestFileGenerator + { + public static void Generate(Exercise exercise) + { + var testClassContents = GenerateTestClassContents(exercise); + var testClassFilePath = TestFilePath(exercise); + + SaveTestClassContentsToFile(testClassFilePath, testClassContents); + Log.Information("Generated tests for {Exercise} exercise in {TestFile}.", exercise.Name, testClassFilePath); + } + + private static string GenerateTestClassContents(Exercise exercise) + { + var canonicalData = CanonicalDataParser.Parse(exercise.Name); + var testClass = exercise.CreateTestClass(canonicalData); + return TestClassRenderer.Render(testClass); + } + + private static void SaveTestClassContentsToFile(string testClassFilePath, string testClassContents) => + File.WriteAllText(testClassFilePath, testClassContents); + + private static string TestFilePath(Exercise exercise) => Path.Combine("..", "exercises", exercise.Name, TestFileName(exercise)); + + private static string TestFileName(Exercise exercise) => $"{exercise.Name.Transform(Humanizer.To.TitleCase)}Test.cs"; + } +} \ No newline at end of file diff --git a/generators/TestClass.cs b/generators/TestClass.cs new file mode 100644 index 0000000000..ee71e89a36 --- /dev/null +++ b/generators/TestClass.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace Generators +{ + public class TestClass + { + public ISet UsingNamespaces { get; set; } = new HashSet { "Xunit" }; + public string ClassName { get; set; } + public string BeforeTestMethods { get; set; } + public TestMethod[] TestMethods { get; set; } + public string AfterTestMethods { get; set; } + } +} \ No newline at end of file diff --git a/generators/TestClassRenderer.cs b/generators/TestClassRenderer.cs new file mode 100644 index 0000000000..b289a80979 --- /dev/null +++ b/generators/TestClassRenderer.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Generators +{ + public static class TestClassRenderer + { + private const string TestClassTemplate = +@"{UsingNamespaces} + +public class {ClassName}Test +{ +{Body} +}"; + + public static string Render(TestClass testClass) => + TestClassTemplate + .Replace("{UsingNamespaces}", RenderUsingNamespaces(testClass)) + .Replace("{ClassName}", testClass.ClassName) + .Replace("{Body}", RenderBody(testClass)); + + private static string RenderUsingNamespaces(TestClass testClass) => + string.Join("\n", testClass.UsingNamespaces.Select(usingNamespace => $"using {usingNamespace};")); + + private static string RenderBody(TestClass testClass) => + string.Join("\n\n", GetBodyParts(testClass)); + + private static IEnumerable GetBodyParts(TestClass testClass) => + from bodyPart in new [] { testClass.BeforeTestMethods, RenderTestMethods(testClass), testClass.AfterTestMethods } + where !string.IsNullOrWhiteSpace(bodyPart) + select bodyPart; + + private static string RenderTestMethods(TestClass testClass) => + string.Join("\n\n", testClass.TestMethods.Select(TestMethodRenderer.Render)); + } +} diff --git a/generators/TestMethod.cs b/generators/TestMethod.cs new file mode 100644 index 0000000000..3e8b67ffc4 --- /dev/null +++ b/generators/TestMethod.cs @@ -0,0 +1,9 @@ +namespace Generators +{ + public class TestMethod + { + public int Index { get; set; } + public string MethodName { get; set; } + public string Body { get; set; } + } +} diff --git a/generators/TestMethodNameTransformer.cs b/generators/TestMethodNameTransformer.cs new file mode 100644 index 0000000000..15115f6e82 --- /dev/null +++ b/generators/TestMethodNameTransformer.cs @@ -0,0 +1,11 @@ +using System.Text.RegularExpressions; +using Humanizer; + +namespace Generators +{ + public class TestMethodNameTransformer : IStringTransformer + { + public string Transform(string input) + => Regex.Replace(input, @"[^\w]+", "_", RegexOptions.Compiled).Underscore().Transform(Humanizer.To.TitleCase); + } +} \ No newline at end of file diff --git a/generators/TestMethodRenderer.cs b/generators/TestMethodRenderer.cs new file mode 100644 index 0000000000..ce941e5920 --- /dev/null +++ b/generators/TestMethodRenderer.cs @@ -0,0 +1,18 @@ +namespace Generators +{ + public static class TestMethodRenderer + { + private const string TestMethodTemplate = +@" [Fact{Skip}] + public void {Name}() + { + {Body} + }"; + + public static string Render(TestMethod testMethod) => + TestMethodTemplate + .Replace("{Name}", testMethod.MethodName) + .Replace("{Body}", testMethod.Body) + .Replace("{Skip}", testMethod.Index == 0 ? "" : "(Skip = \"Remove to run test\")"); + } +} diff --git a/generators/To.cs b/generators/To.cs new file mode 100644 index 0000000000..8a459165cb --- /dev/null +++ b/generators/To.cs @@ -0,0 +1,7 @@ +namespace Generators +{ + public static class To + { + public static readonly TestMethodNameTransformer TestMethodName = new TestMethodNameTransformer(); + } +} \ No newline at end of file diff --git a/generators/generate.ps1 b/generators/generate.ps1 new file mode 100644 index 0000000000..e43799710c --- /dev/null +++ b/generators/generate.ps1 @@ -0,0 +1,2 @@ +Invoke-Expression "dotnet restore" +Invoke-Expression "dotnet run" \ No newline at end of file diff --git a/generators/generate.sh b/generators/generate.sh new file mode 100644 index 0000000000..2f8a56bcd6 --- /dev/null +++ b/generators/generate.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +exec dotnet restore +exec dotnet run \ No newline at end of file