From 2b4953289b67d8e42e3e0dbeb09a13ef91677dcb Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Fri, 26 Dec 2025 16:43:07 +0300 Subject: [PATCH 01/10] init project --- HW6/HW6.sln | 22 +++++++++ HW6/MyNunitWeb.Api/AppDBContext.cs | 32 +++++++++++++ HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj | 34 ++++++++++++++ HW6/MyNunitWeb.Api/Program.cs | 10 ++++ HW6/MyNunitWeb.Api/TestResult.cs | 46 +++++++++++++++++++ HW6/MyNunitWeb.Api/TestRun.cs | 41 +++++++++++++++++ .../appsettings.Development.json | 8 ++++ HW6/MyNunitWeb.Api/appsettings.json | 9 ++++ HW6/MyNunitWeb.Api/stylecop.json | 9 ++++ 9 files changed, 211 insertions(+) create mode 100644 HW6/HW6.sln create mode 100644 HW6/MyNunitWeb.Api/AppDBContext.cs create mode 100644 HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj create mode 100644 HW6/MyNunitWeb.Api/Program.cs create mode 100644 HW6/MyNunitWeb.Api/TestResult.cs create mode 100644 HW6/MyNunitWeb.Api/TestRun.cs create mode 100644 HW6/MyNunitWeb.Api/appsettings.Development.json create mode 100644 HW6/MyNunitWeb.Api/appsettings.json create mode 100644 HW6/MyNunitWeb.Api/stylecop.json diff --git a/HW6/HW6.sln b/HW6/HW6.sln new file mode 100644 index 0000000..a9a9ea0 --- /dev/null +++ b/HW6/HW6.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNunitWeb.Server", "MyNunitWeb.Server\MyNunitWeb.Server.csproj", "{7E056C49-469E-4CCE-B46D-DAAB9542FD05}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNunitWeb.Api", "MyNunitWeb.Api\MyNunitWeb.Api.csproj", "{F3460FEE-64A5-46D3-BFC6-1FC65305B172}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7E056C49-469E-4CCE-B46D-DAAB9542FD05}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7E056C49-469E-4CCE-B46D-DAAB9542FD05}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E056C49-469E-4CCE-B46D-DAAB9542FD05}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7E056C49-469E-4CCE-B46D-DAAB9542FD05}.Release|Any CPU.Build.0 = Release|Any CPU + {F3460FEE-64A5-46D3-BFC6-1FC65305B172}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F3460FEE-64A5-46D3-BFC6-1FC65305B172}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F3460FEE-64A5-46D3-BFC6-1FC65305B172}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F3460FEE-64A5-46D3-BFC6-1FC65305B172}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/HW6/MyNunitWeb.Api/AppDBContext.cs b/HW6/MyNunitWeb.Api/AppDBContext.cs new file mode 100644 index 0000000..35f0a11 --- /dev/null +++ b/HW6/MyNunitWeb.Api/AppDBContext.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) MyNunitWeb. All rights reserved. +// + +namespace MyNunitWeb.Api; + +using Microsoft.EntityFrameworkCore; + +/// +/// Entity Framework database context for MyNUnit test runs. +/// +public class AppDbContext : DbContext +{ + /// + /// Initializes a new instance of the class. + /// + /// Database context options. + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + /// + /// Gets the collection of test runs. + /// + public DbSet TestRuns => this.Set(); + + /// + /// Gets the collection of test results. + /// + public DbSet TestResults => this.Set(); +} \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj new file mode 100644 index 0000000..3606583 --- /dev/null +++ b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/HW6/MyNunitWeb.Api/Program.cs b/HW6/MyNunitWeb.Api/Program.cs new file mode 100644 index 0000000..47968e2 --- /dev/null +++ b/HW6/MyNunitWeb.Api/Program.cs @@ -0,0 +1,10 @@ +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); + +var app = builder.Build(); + +app.UseHttpsRedirection(); +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/TestResult.cs b/HW6/MyNunitWeb.Api/TestResult.cs new file mode 100644 index 0000000..18b3975 --- /dev/null +++ b/HW6/MyNunitWeb.Api/TestResult.cs @@ -0,0 +1,46 @@ +// +// Copyright (c) MyNunitWeb. All rights reserved. +// + +namespace MyNunitWeb.Api; + +using MyNUnit; +using System.ComponentModel.DataAnnotations; + +/// +/// Represents a single test execution result. +/// +public class TestResult +{ + /// + /// Gets or sets the identifier of the test result. + /// + public int Id { get; set; } + + /// + /// Gets or sets the test name. + /// + [MaxLength(128)] + public string TestName { get; set; } = string.Empty; + + /// + /// Gets or sets the execution status of the test. + /// + public TestStatus Status { get; set; } + + /// + /// Gets or sets the test duration in milliseconds. + /// + public double DurationMs { get; set; } + + /// + /// Gets or sets the message associated with the test result. + /// + [MaxLength(256)] + public string? Message { get; set; } + + /// + /// Gets or sets the related test run identifier. + /// + public int TestRunId { get; set; } +} \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/TestRun.cs b/HW6/MyNunitWeb.Api/TestRun.cs new file mode 100644 index 0000000..d37ec0a --- /dev/null +++ b/HW6/MyNunitWeb.Api/TestRun.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) MyNunitWeb. All rights reserved. +// + +namespace MyNunitWeb.Api; + +/// +/// Represents a single execution of a test assembly. +/// +public class TestRun +{ + /// + /// Gets or sets the identifier of the test run. + /// + public int Id { get; set; } + + /// + /// Gets or sets the start time of the test run. + /// + public DateTime StartedAt { get; set; } + + /// + /// Gets or sets the number of passed tests. + /// + public int Passed { get; set; } + + /// + /// Gets or sets the number of failed tests. + /// + public int Failed { get; set; } + + /// + /// Gets or sets the number of ignored tests. + /// + public int Ignored { get; set; } + + /// + /// Gets or sets the test results of this run. + /// + public List Results { get; set; } = []; +} \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/appsettings.Development.json b/HW6/MyNunitWeb.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/HW6/MyNunitWeb.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/HW6/MyNunitWeb.Api/appsettings.json b/HW6/MyNunitWeb.Api/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/HW6/MyNunitWeb.Api/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/HW6/MyNunitWeb.Api/stylecop.json b/HW6/MyNunitWeb.Api/stylecop.json new file mode 100644 index 0000000..9bb4698 --- /dev/null +++ b/HW6/MyNunitWeb.Api/stylecop.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "khusainovilas", + "copyrightText": "Copyright (c) {companyName}. All rights reserved." + } + } +} \ No newline at end of file From 3fe3e3c950a6f28de12912892bc1d4a536d94edc Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Fri, 26 Dec 2025 19:54:19 +0300 Subject: [PATCH 02/10] save changes --- HW6/HW6.sln | 6 ++++++ HW6/MyNUnit/MyNUnit.csproj | 10 ++++++++++ HW6/MyNUnit/Program.cs | 3 +++ HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj | 1 - HW6/MyNunitWeb.Api/TestResult.cs | 2 +- 5 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 HW6/MyNUnit/MyNUnit.csproj create mode 100644 HW6/MyNUnit/Program.cs diff --git a/HW6/HW6.sln b/HW6/HW6.sln index a9a9ea0..6787853 100644 --- a/HW6/HW6.sln +++ b/HW6/HW6.sln @@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNunitWeb.Server", "MyNuni EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNunitWeb.Api", "MyNunitWeb.Api\MyNunitWeb.Api.csproj", "{F3460FEE-64A5-46D3-BFC6-1FC65305B172}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNUnit", "MyNUnit\MyNUnit.csproj", "{E723C040-0075-424E-A25F-0380CD4169BD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +20,9 @@ Global {F3460FEE-64A5-46D3-BFC6-1FC65305B172}.Debug|Any CPU.Build.0 = Debug|Any CPU {F3460FEE-64A5-46D3-BFC6-1FC65305B172}.Release|Any CPU.ActiveCfg = Release|Any CPU {F3460FEE-64A5-46D3-BFC6-1FC65305B172}.Release|Any CPU.Build.0 = Release|Any CPU + {E723C040-0075-424E-A25F-0380CD4169BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E723C040-0075-424E-A25F-0380CD4169BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E723C040-0075-424E-A25F-0380CD4169BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E723C040-0075-424E-A25F-0380CD4169BD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/HW6/MyNUnit/MyNUnit.csproj b/HW6/MyNUnit/MyNUnit.csproj new file mode 100644 index 0000000..85b4959 --- /dev/null +++ b/HW6/MyNUnit/MyNUnit.csproj @@ -0,0 +1,10 @@ + + + + Exe + net9.0 + enable + enable + + + diff --git a/HW6/MyNUnit/Program.cs b/HW6/MyNUnit/Program.cs new file mode 100644 index 0000000..e5dff12 --- /dev/null +++ b/HW6/MyNUnit/Program.cs @@ -0,0 +1,3 @@ +// See https://aka.ms/new-console-template for more information + +Console.WriteLine("Hello, World!"); \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj index 3606583..d4c7548 100644 --- a/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj +++ b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj @@ -24,7 +24,6 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - diff --git a/HW6/MyNunitWeb.Api/TestResult.cs b/HW6/MyNunitWeb.Api/TestResult.cs index 18b3975..53ddf28 100644 --- a/HW6/MyNunitWeb.Api/TestResult.cs +++ b/HW6/MyNunitWeb.Api/TestResult.cs @@ -4,8 +4,8 @@ namespace MyNunitWeb.Api; -using MyNUnit; using System.ComponentModel.DataAnnotations; +using MyNunit; /// /// Represents a single test execution result. From ca7c7389dd985a536b28abfd49030e77e378ae67 Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Fri, 26 Dec 2025 19:55:02 +0300 Subject: [PATCH 03/10] save changes --- HW6/MyNunitWeb.Api/TestResult.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HW6/MyNunitWeb.Api/TestResult.cs b/HW6/MyNunitWeb.Api/TestResult.cs index 53ddf28..18b3975 100644 --- a/HW6/MyNunitWeb.Api/TestResult.cs +++ b/HW6/MyNunitWeb.Api/TestResult.cs @@ -4,8 +4,8 @@ namespace MyNunitWeb.Api; +using MyNUnit; using System.ComponentModel.DataAnnotations; -using MyNunit; /// /// Represents a single test execution result. From 62cc881be9dd4cb6d893ce2f342f65e36e7290ce Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Fri, 26 Dec 2025 20:07:10 +0300 Subject: [PATCH 04/10] include in the mynunit project --- HW6/MyNUnit/Attributes.cs | 70 ++++++++ HW6/MyNUnit/ConsoleReporter.cs | 89 +++++++++ HW6/MyNUnit/MyNUnit.csproj | 12 ++ HW6/MyNUnit/Program.cs | 40 ++++- HW6/MyNUnit/TestDiscovery.cs | 89 +++++++++ HW6/MyNUnit/TestResult.cs | 59 ++++++ HW6/MyNUnit/TestRunner.cs | 220 +++++++++++++++++++++++ HW6/MyNUnit/stylecop.json | 9 + HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj | 4 + 9 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 HW6/MyNUnit/Attributes.cs create mode 100644 HW6/MyNUnit/ConsoleReporter.cs create mode 100644 HW6/MyNUnit/TestDiscovery.cs create mode 100644 HW6/MyNUnit/TestResult.cs create mode 100644 HW6/MyNUnit/TestRunner.cs create mode 100644 HW6/MyNUnit/stylecop.json diff --git a/HW6/MyNUnit/Attributes.cs b/HW6/MyNUnit/Attributes.cs new file mode 100644 index 0000000..46f2bac --- /dev/null +++ b/HW6/MyNUnit/Attributes.cs @@ -0,0 +1,70 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNUnit; + +/// +/// Marks the method as a test. +/// +[AttributeUsage(AttributeTargets.Method)] +public class TestAttribute : Attribute +{ + /// + /// Gets or sets the expected exception type. + /// + public Type? Expected { get; set; } +} + +/// +/// Skip the test with an indication of the reason. +/// +[AttributeUsage(AttributeTargets.Method)] +public class IgnoreAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The reason for ignoring the test. + public IgnoreAttribute(string reason) + { + this.Reason = reason ?? throw new ArgumentNullException(nameof(reason)); + } + + /// + /// Gets the reason why the test is ignored. + /// + public string Reason { get; } +} + +/// +/// Runs once before all tests in the class. +/// +[AttributeUsage(AttributeTargets.Method)] +public class BeforeClassAttribute : Attribute +{ +} + +/// +/// Runs once after all the tests in the class. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class AfterClassAttribute : Attribute +{ +} + +/// +/// Runs before each test. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class BeforeAttribute : Attribute +{ +} + +/// +/// Runs after each test. +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public class AfterAttribute : Attribute +{ +} diff --git a/HW6/MyNUnit/ConsoleReporter.cs b/HW6/MyNUnit/ConsoleReporter.cs new file mode 100644 index 0000000..5f72050 --- /dev/null +++ b/HW6/MyNUnit/ConsoleReporter.cs @@ -0,0 +1,89 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNUnit; + +using System; +using System.Collections.Generic; +using System.Linq; + +/// +/// Prints test results to console. +/// +public static class ConsoleReporter +{ + /// + /// Prints test execution report. + /// + /// A collection of objects to print. + public static void Print(IEnumerable results) + { + ArgumentNullException.ThrowIfNull(results); + + var list = results.ToList(); + + foreach (var result in list.OrderBy(r => r.TestName)) + { + PrintSingle(result); + } + + PrintSummary(list); + } + + private static void PrintSingle(TestResult result) + { + switch (result.Status) + { + case TestStatus.Passed: + Console.WriteLine( + $"[PASS] {result.TestName} ({Format(result.Duration)})"); + break; + + case TestStatus.Failed: + Console.WriteLine( + $"[FAIL] {result.TestName} ({Format(result.Duration)})"); + + if (!string.IsNullOrWhiteSpace(result.Message)) + { + Console.WriteLine($" {result.Message}"); + } + + if (result.Exception != null) + { + Console.WriteLine( + $" {result.Exception.GetType().Name}: {result.Exception.Message}"); + } + + break; + + case TestStatus.Ignored: + Console.WriteLine($"[IGNORED] {result.TestName}"); + Console.WriteLine($" Reason: {result.Message}"); + break; + } + } + + private static void PrintSummary(IReadOnlyCollection results) + { + Console.WriteLine(); + Console.WriteLine(new string('=', 40)); + + Console.WriteLine($"Total: {results.Count}"); + Console.WriteLine($"Passed: {results.Count(r => r.Status == TestStatus.Passed)}"); + Console.WriteLine($"Failed: {results.Count(r => r.Status == TestStatus.Failed)}"); + Console.WriteLine($"Ignored: {results.Count(r => r.Status == TestStatus.Ignored)}"); + + var totalTime = TimeSpan.FromTicks(results.Sum(r => r.Duration.Ticks)); + Console.WriteLine($"Time: {Format(totalTime)}"); + + Console.WriteLine(new string('=', 40)); + } + + private static string Format(TimeSpan time) + { + return time.TotalMilliseconds < 1 + ? "<1 ms" + : $"{time.TotalMilliseconds:F0} ms"; + } +} diff --git a/HW6/MyNUnit/MyNUnit.csproj b/HW6/MyNUnit/MyNUnit.csproj index 85b4959..3fef999 100644 --- a/HW6/MyNUnit/MyNUnit.csproj +++ b/HW6/MyNUnit/MyNUnit.csproj @@ -5,6 +5,18 @@ net9.0 enable enable + true + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/HW6/MyNUnit/Program.cs b/HW6/MyNUnit/Program.cs index e5dff12..2299290 100644 --- a/HW6/MyNUnit/Program.cs +++ b/HW6/MyNUnit/Program.cs @@ -1,3 +1,39 @@ -// See https://aka.ms/new-console-template for more information +// +// Copyright (c) khusainovilas. All rights reserved. +// -Console.WriteLine("Hello, World!"); \ No newline at end of file +using MyNUnit; + +if (args.Length != 1) +{ + PrintUsage(); + return; +} + +try +{ + var testClasses = TestDiscovery.Discover(args[0]); + + if (testClasses.Count == 0) + { + Console.WriteLine("No tests found."); + return; + } + + var results = TestRunner.Run(testClasses); + ConsoleReporter.Print(results); +} +catch (Exception ex) +{ + Console.WriteLine("[ERROR]"); + Console.WriteLine(ex.Message); +} + +static void PrintUsage() +{ + Console.WriteLine("Usage:"); + Console.WriteLine(" MyNUnit "); + Console.WriteLine(); + Console.WriteLine("Arguments:"); + Console.WriteLine(" Path to directory or .dll file containing tests."); +} diff --git a/HW6/MyNUnit/TestDiscovery.cs b/HW6/MyNUnit/TestDiscovery.cs new file mode 100644 index 0000000..8b9a2ab --- /dev/null +++ b/HW6/MyNUnit/TestDiscovery.cs @@ -0,0 +1,89 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNUnit; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; + +/// +/// Searches for test classes and test methods in assemblies. +/// +public static class TestDiscovery +{ + /// + /// Finds all test classes in the specified path. + /// + /// Path to directory or .dll file. + /// List of test class types. + public static IReadOnlyList Discover(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var result = new List(); + + foreach (var assembly in LoadAssemblies(path)) + { + foreach (var type in assembly.GetTypes()) + { + if (ContainsTests(type)) + { + result.Add(type); + } + } + } + + return result; + } + + private static bool ContainsTests(Type type) + { + return type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .Any(m => m.GetCustomAttribute() != null); + } + + private static IEnumerable LoadAssemblies(string path) + { + if (Directory.Exists(path)) + { + foreach (var file in Directory.GetFiles(path, "*.dll", SearchOption.AllDirectories)) + { + var assembly = TryLoadAssembly(file); + if (assembly != null) + { + yield return assembly; + } + } + } + else if (File.Exists(path) && path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + var assembly = TryLoadAssembly(path); + if (assembly != null) + { + yield return assembly; + } + } + else + { + throw new FileNotFoundException($"Path not found: {path}"); + } + } + + private static Assembly? TryLoadAssembly(string filePath) + { + try + { + return Assembly.LoadFrom(filePath); + } + catch (Exception ex) when (ex is BadImageFormatException or FileLoadException) + { + Console.WriteLine($"[Warning] Failed to load assembly: {filePath}"); + Console.WriteLine($" {ex.Message}"); + return null; + } + } +} \ No newline at end of file diff --git a/HW6/MyNUnit/TestResult.cs b/HW6/MyNUnit/TestResult.cs new file mode 100644 index 0000000..2f0fa4f --- /dev/null +++ b/HW6/MyNUnit/TestResult.cs @@ -0,0 +1,59 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNUnit; + +using System; + +/// +/// Represents the result of a single test execution. +/// +public class TestResult +{ + /// + /// Gets test full name. + /// + public string TestName { get; init; } = string.Empty; + + /// + /// Gets test execution status. + /// + public TestStatus Status { get; init; } + + /// + /// Gets test execution duration. + /// + public TimeSpan Duration { get; init; } + + /// + /// Gets additional message (failure reason or ignore reason). + /// + public string? Message { get; init; } + + /// + /// Gets exception thrown by test. + /// + public Exception? Exception { get; init; } +} + +/// +/// Test execution status. +/// +public enum TestStatus +{ + /// + /// Test passed successfully. + /// + Passed, + + /// + /// Test failed. + /// + Failed, + + /// + /// Test was ignored. + /// + Ignored, +} \ No newline at end of file diff --git a/HW6/MyNUnit/TestRunner.cs b/HW6/MyNUnit/TestRunner.cs new file mode 100644 index 0000000..6bd4e40 --- /dev/null +++ b/HW6/MyNUnit/TestRunner.cs @@ -0,0 +1,220 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNUnit; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +/// +/// Executes discovered tests. +/// +public static class TestRunner +{ + /// + /// Runs all tests in given test classes. + /// + /// Collection of types containing test methods. + /// + /// Collection of results for all executed tests. + /// + public static IReadOnlyList Run(IEnumerable testClasses) + { + ArgumentNullException.ThrowIfNull(testClasses); + + var results = new ConcurrentBag(); + + Parallel.ForEach(testClasses, testClass => + { + RunTestsInClass(testClass, results); + }); + + return results.ToList(); + } + + private static void RunTestsInClass(Type testClass, ConcurrentBag results) + { + var beforeClass = GetMethods(testClass, true); + var afterClass = GetMethods(testClass, true); + var before = GetMethods(testClass, false); + var after = GetMethods(testClass, false); + var tests = GetMethods(testClass, false); + + try + { + InvokeStatic(beforeClass); + + foreach (var test in tests) + { + results.Add(RunSingleTest(testClass, test, before, after)); + } + } + catch (Exception ex) + { + foreach (var test in tests) + { + results.Add(new TestResult + { + TestName = $"{testClass.FullName}.{test.Name}", + Status = TestStatus.Failed, + Message = "BeforeClass failed", + Exception = ex, + }); + } + } + finally + { + InvokeStatic(afterClass); + } + } + + private static TestResult RunSingleTest(Type testClass, MethodInfo testMethod, IReadOnlyList before, IReadOnlyList after) + { + var testName = $"{testClass.FullName}.{testMethod.Name}"; + var ignore = testMethod.GetCustomAttribute(); + var testAttr = testMethod.GetCustomAttribute(); + + if (ignore != null) + { + return new TestResult + { + TestName = testName, + Status = TestStatus.Ignored, + Message = ignore.Reason, + Duration = TimeSpan.Zero, + }; + } + + if (!IsValidTestMethod(testMethod)) + { + return new TestResult + { + TestName = testName, + Status = TestStatus.Failed, + Message = "Test method must be public void with no parameters", + }; + } + + var stopwatch = Stopwatch.StartNew(); + object? instance = null; + + try + { + instance = Activator.CreateInstance(testClass) ?? throw new InvalidOperationException($"Failed to create instance of {testClass.FullName}"); + InvokeInstance(before, instance); + testMethod.Invoke(instance, null); + InvokeInstance(after, instance); + + stopwatch.Stop(); + + if (testAttr?.Expected != null) + { + return Fail(testName, "Expected exception was not thrown", stopwatch.Elapsed); + } + + return Pass(testName, stopwatch.Elapsed); + } + catch (TargetInvocationException ex) + { + stopwatch.Stop(); + InvokeAfterSafely(after, instance); + + var actual = ex.InnerException!; + + if (testAttr?.Expected != null && + testAttr.Expected.IsAssignableFrom(actual.GetType())) + { + return Pass(testName, stopwatch.Elapsed); + } + + return Fail(testName, actual.Message, stopwatch.Elapsed, actual); + } + catch (Exception ex) + { + stopwatch.Stop(); + InvokeAfterSafely(after, instance); + + return Fail(testName, ex.Message, stopwatch.Elapsed, ex); + } + } + + private static bool IsValidTestMethod(MethodInfo method) + { + return method.IsPublic && + !method.IsStatic && + method.ReturnType == typeof(void) && + method.GetParameters().Length == 0; + } + + private static IReadOnlyList GetMethods(Type type, bool isStatic) + where T : Attribute => type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | + (isStatic ? BindingFlags.Static : BindingFlags.Instance)) + .Where(m => m.GetCustomAttribute() != null) + .ToList(); + + private static void InvokeStatic(IEnumerable methods) + { + foreach (var method in methods) + { + if (!method.IsStatic) + { + throw new InvalidOperationException("BeforeClass/AfterClass must be static"); + } + + method.Invoke(null, null); + } + } + + private static void InvokeInstance(IEnumerable methods, object instance) + { + foreach (var method in methods) + { + method.Invoke(instance, null); + } + } + + private static void InvokeAfterSafely(IEnumerable methods, object? instance) + { + if (instance == null) + { + return; + } + + try + { + InvokeInstance(methods, instance); + } + catch + { + // ignored + } + } + + private static TestResult Pass(string name, TimeSpan time) + { + return new TestResult + { + TestName = name, + Status = TestStatus.Passed, + Duration = time, + }; + } + + private static TestResult Fail(string name, string message, TimeSpan time = default, Exception? exception = null) + { + return new TestResult + { + TestName = name, + Status = TestStatus.Failed, + Duration = time, + Message = message, + Exception = exception, + }; + } +} diff --git a/HW6/MyNUnit/stylecop.json b/HW6/MyNUnit/stylecop.json new file mode 100644 index 0000000..76c8e76 --- /dev/null +++ b/HW6/MyNUnit/stylecop.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "documentationRules": { + "companyName": "khusainovilas", + "copyrightText": "Copyright (c) {companyName}. All rights reserved." + } + } +} \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj index d4c7548..3881e7f 100644 --- a/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj +++ b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj @@ -30,4 +30,8 @@ + + + + From 1152f607505ba66b63e0fb3d13a85c964fcc2983 Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Sun, 28 Dec 2025 22:14:45 +0300 Subject: [PATCH 05/10] add frontend and reorganize proj --- HW6/HW6.sln | 2 - HW6/MyNUnit/MyNUnit.csproj | 2 +- HW6/MyNUnit/TestDiscovery.cs | 76 +++------ HW6/MyNUnit/TestResult.cs | 5 - HW6/MyNUnit/TestRunner.cs | 10 +- HW6/MyNunitWeb.Api/AppDBContext.cs | 30 +++- .../20251228190820_InitialCreate.Designer.cs | 106 ++++++++++++ .../20251228190820_InitialCreate.cs | 85 ++++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 103 ++++++++++++ HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj | 8 +- HW6/MyNunitWeb.Api/Program.cs | 12 +- HW6/MyNunitWeb.Api/TestController.cs | 104 ++++++++++++ HW6/MyNunitWeb.Api/TestResult.cs | 15 +- HW6/MyNunitWeb.Api/TestRun.cs | 11 +- HW6/MyNunitWeb.Client/app.js | 146 +++++++++++++++++ HW6/MyNunitWeb.Client/index.html | 39 +++++ HW6/MyNunitWeb.Client/style.css | 154 ++++++++++++++++++ 17 files changed, 829 insertions(+), 79 deletions(-) create mode 100644 HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.Designer.cs create mode 100644 HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.cs create mode 100644 HW6/MyNunitWeb.Api/Migrations/AppDbContextModelSnapshot.cs create mode 100644 HW6/MyNunitWeb.Api/TestController.cs create mode 100644 HW6/MyNunitWeb.Client/app.js create mode 100644 HW6/MyNunitWeb.Client/index.html create mode 100644 HW6/MyNunitWeb.Client/style.css diff --git a/HW6/HW6.sln b/HW6/HW6.sln index 6787853..eda89b3 100644 --- a/HW6/HW6.sln +++ b/HW6/HW6.sln @@ -1,7 +1,5 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNunitWeb.Server", "MyNunitWeb.Server\MyNunitWeb.Server.csproj", "{7E056C49-469E-4CCE-B46D-DAAB9542FD05}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNunitWeb.Api", "MyNunitWeb.Api\MyNunitWeb.Api.csproj", "{F3460FEE-64A5-46D3-BFC6-1FC65305B172}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyNUnit", "MyNUnit\MyNUnit.csproj", "{E723C040-0075-424E-A25F-0380CD4169BD}" diff --git a/HW6/MyNUnit/MyNUnit.csproj b/HW6/MyNUnit/MyNUnit.csproj index 3fef999..7dc5827 100644 --- a/HW6/MyNUnit/MyNUnit.csproj +++ b/HW6/MyNUnit/MyNUnit.csproj @@ -1,7 +1,7 @@  - Exe + Library net9.0 enable enable diff --git a/HW6/MyNUnit/TestDiscovery.cs b/HW6/MyNUnit/TestDiscovery.cs index 8b9a2ab..6925abb 100644 --- a/HW6/MyNUnit/TestDiscovery.cs +++ b/HW6/MyNUnit/TestDiscovery.cs @@ -16,74 +16,38 @@ namespace MyNUnit; public static class TestDiscovery { /// - /// Finds all test classes in the specified path. + /// Discovers test classes in a DLL. /// - /// Path to directory or .dll file. + /// Path to test assembly. /// List of test class types. - public static IReadOnlyList Discover(string path) + public static IReadOnlyList DiscoverFromDll(string dllPath) { - ArgumentException.ThrowIfNullOrWhiteSpace(path); + ArgumentException.ThrowIfNullOrWhiteSpace(dllPath); - var result = new List(); + if (!File.Exists(dllPath)) + { + throw new FileNotFoundException("DLL not found", dllPath); + } - foreach (var assembly in LoadAssemblies(path)) + if (!dllPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) { - foreach (var type in assembly.GetTypes()) - { - if (ContainsTests(type)) - { - result.Add(type); - } - } + throw new ArgumentException("File must be a DLL", nameof(dllPath)); } - return result; + var assembly = Assembly.LoadFrom(dllPath); + + return assembly + .GetTypes() + .Where(ContainsTests) + .ToList(); } private static bool ContainsTests(Type type) { - return type.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + return type.GetMethods( + BindingFlags.Instance | + BindingFlags.Public | + BindingFlags.NonPublic) .Any(m => m.GetCustomAttribute() != null); } - - private static IEnumerable LoadAssemblies(string path) - { - if (Directory.Exists(path)) - { - foreach (var file in Directory.GetFiles(path, "*.dll", SearchOption.AllDirectories)) - { - var assembly = TryLoadAssembly(file); - if (assembly != null) - { - yield return assembly; - } - } - } - else if (File.Exists(path) && path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) - { - var assembly = TryLoadAssembly(path); - if (assembly != null) - { - yield return assembly; - } - } - else - { - throw new FileNotFoundException($"Path not found: {path}"); - } - } - - private static Assembly? TryLoadAssembly(string filePath) - { - try - { - return Assembly.LoadFrom(filePath); - } - catch (Exception ex) when (ex is BadImageFormatException or FileLoadException) - { - Console.WriteLine($"[Warning] Failed to load assembly: {filePath}"); - Console.WriteLine($" {ex.Message}"); - return null; - } - } } \ No newline at end of file diff --git a/HW6/MyNUnit/TestResult.cs b/HW6/MyNUnit/TestResult.cs index 2f0fa4f..06bd893 100644 --- a/HW6/MyNUnit/TestResult.cs +++ b/HW6/MyNUnit/TestResult.cs @@ -30,11 +30,6 @@ public class TestResult /// Gets additional message (failure reason or ignore reason). /// public string? Message { get; init; } - - /// - /// Gets exception thrown by test. - /// - public Exception? Exception { get; init; } } /// diff --git a/HW6/MyNUnit/TestRunner.cs b/HW6/MyNUnit/TestRunner.cs index 6bd4e40..c9e4cf5 100644 --- a/HW6/MyNUnit/TestRunner.cs +++ b/HW6/MyNUnit/TestRunner.cs @@ -63,8 +63,7 @@ private static void RunTestsInClass(Type testClass, ConcurrentBag re { TestName = $"{testClass.FullName}.{test.Name}", Status = TestStatus.Failed, - Message = "BeforeClass failed", - Exception = ex, + Message = $"BeforeClass failed: {ex.Message}", }); } } @@ -133,14 +132,14 @@ private static TestResult RunSingleTest(Type testClass, MethodInfo testMethod, I return Pass(testName, stopwatch.Elapsed); } - return Fail(testName, actual.Message, stopwatch.Elapsed, actual); + return Fail(testName, actual.Message, stopwatch.Elapsed); } catch (Exception ex) { stopwatch.Stop(); InvokeAfterSafely(after, instance); - return Fail(testName, ex.Message, stopwatch.Elapsed, ex); + return Fail(testName, ex.Message, stopwatch.Elapsed); } } @@ -206,7 +205,7 @@ private static TestResult Pass(string name, TimeSpan time) }; } - private static TestResult Fail(string name, string message, TimeSpan time = default, Exception? exception = null) + private static TestResult Fail(string name, string message, TimeSpan time = default) { return new TestResult { @@ -214,7 +213,6 @@ private static TestResult Fail(string name, string message, TimeSpan time = defa Status = TestStatus.Failed, Duration = time, Message = message, - Exception = exception, }; } } diff --git a/HW6/MyNunitWeb.Api/AppDBContext.cs b/HW6/MyNunitWeb.Api/AppDBContext.cs index 35f0a11..ff2ace3 100644 --- a/HW6/MyNunitWeb.Api/AppDBContext.cs +++ b/HW6/MyNunitWeb.Api/AppDBContext.cs @@ -1,5 +1,5 @@ -// -// Copyright (c) MyNunitWeb. All rights reserved. +// +// Copyright (c) khusainovilas. All rights reserved. // namespace MyNunitWeb.Api; @@ -29,4 +29,30 @@ public AppDbContext(DbContextOptions options) /// Gets the collection of test results. /// public DbSet TestResults => this.Set(); + + /// + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + modelBuilder.Entity() + .HasMany(r => r.Results) + .WithOne() + .HasForeignKey(r => r.TestRunId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(r => r.StartedAt); + + modelBuilder.Entity() + .HasIndex(r => r.TestRunId); + + modelBuilder.Entity() + .Property(r => r.TestName) + .HasMaxLength(128); + + modelBuilder.Entity() + .Property(r => r.Message) + .HasMaxLength(256); + } } \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.Designer.cs b/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.Designer.cs new file mode 100644 index 0000000..1322cc2 --- /dev/null +++ b/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.Designer.cs @@ -0,0 +1,106 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyNunitWeb.Api; + +#nullable disable + +namespace MyNunitWeb.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20251228190820_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + + modelBuilder.Entity("MyNunitWeb.Api.TestResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DurationMs") + .HasColumnType("REAL"); + + b.Property("Message") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TestName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TestRunId") + .HasColumnType("INTEGER"); + + b.Property("TestRunId1") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TestRunId"); + + b.HasIndex("TestRunId1"); + + b.ToTable("TestResults"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.TestRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Failed") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Passed") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("TestRuns"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.TestResult", b => + { + b.HasOne("MyNunitWeb.Api.TestRun", null) + .WithMany("Results") + .HasForeignKey("TestRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyNunitWeb.Api.TestRun", "TestRun") + .WithMany() + .HasForeignKey("TestRunId1"); + + b.Navigation("TestRun"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.TestRun", b => + { + b.Navigation("Results"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.cs b/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.cs new file mode 100644 index 0000000..830b04a --- /dev/null +++ b/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.cs @@ -0,0 +1,85 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyNunitWeb.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "TestRuns", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + StartedAt = table.Column(type: "TEXT", nullable: false), + Passed = table.Column(type: "INTEGER", nullable: false), + Failed = table.Column(type: "INTEGER", nullable: false), + Ignored = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TestRuns", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TestResults", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TestName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + DurationMs = table.Column(type: "REAL", nullable: false), + Message = table.Column(type: "TEXT", maxLength: 256, nullable: true), + TestRunId = table.Column(type: "INTEGER", nullable: false), + TestRunId1 = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_TestResults", x => x.Id); + table.ForeignKey( + name: "FK_TestResults_TestRuns_TestRunId", + column: x => x.TestRunId, + principalTable: "TestRuns", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_TestResults_TestRuns_TestRunId1", + column: x => x.TestRunId1, + principalTable: "TestRuns", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_TestResults_TestRunId", + table: "TestResults", + column: "TestRunId"); + + migrationBuilder.CreateIndex( + name: "IX_TestResults_TestRunId1", + table: "TestResults", + column: "TestRunId1"); + + migrationBuilder.CreateIndex( + name: "IX_TestRuns_StartedAt", + table: "TestRuns", + column: "StartedAt"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TestResults"); + + migrationBuilder.DropTable( + name: "TestRuns"); + } + } +} diff --git a/HW6/MyNunitWeb.Api/Migrations/AppDbContextModelSnapshot.cs b/HW6/MyNunitWeb.Api/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..4fffff3 --- /dev/null +++ b/HW6/MyNunitWeb.Api/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,103 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyNunitWeb.Api; + +#nullable disable + +namespace MyNunitWeb.Api.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); + + modelBuilder.Entity("MyNunitWeb.Api.TestResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DurationMs") + .HasColumnType("REAL"); + + b.Property("Message") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TestName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TestRunId") + .HasColumnType("INTEGER"); + + b.Property("TestRunId1") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TestRunId"); + + b.HasIndex("TestRunId1"); + + b.ToTable("TestResults"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.TestRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Failed") + .HasColumnType("INTEGER"); + + b.Property("Ignored") + .HasColumnType("INTEGER"); + + b.Property("Passed") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StartedAt"); + + b.ToTable("TestRuns"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.TestResult", b => + { + b.HasOne("MyNunitWeb.Api.TestRun", null) + .WithMany("Results") + .HasForeignKey("TestRunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MyNunitWeb.Api.TestRun", "TestRun") + .WithMany() + .HasForeignKey("TestRunId1"); + + b.Navigation("TestRun"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.TestRun", b => + { + b.Navigation("Results"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj index 3881e7f..930f0ac 100644 --- a/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj +++ b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj @@ -8,15 +8,19 @@ - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/HW6/MyNunitWeb.Api/Program.cs b/HW6/MyNunitWeb.Api/Program.cs index 47968e2..54a2ba2 100644 --- a/HW6/MyNunitWeb.Api/Program.cs +++ b/HW6/MyNunitWeb.Api/Program.cs @@ -1,5 +1,15 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +using Microsoft.EntityFrameworkCore; +using MyNunitWeb.Api; + var builder = WebApplication.CreateBuilder(args); +builder.Services.AddDbContext(options => + options.UseSqlite("Data Source=MyNUnit.db")); + builder.Services.AddControllers(); var app = builder.Build(); @@ -7,4 +17,4 @@ app.UseHttpsRedirection(); app.MapControllers(); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/HW6/MyNunitWeb.Api/TestController.cs b/HW6/MyNunitWeb.Api/TestController.cs new file mode 100644 index 0000000..adc67d9 --- /dev/null +++ b/HW6/MyNunitWeb.Api/TestController.cs @@ -0,0 +1,104 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNunitWeb.Api; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MyNUnit; + +/// +/// Controller for uploading assemblies and running tests. +/// +[ApiController] +[Route("api/[controller]")] +public class TestController : ControllerBase +{ + private readonly AppDbContext dbContext; + private readonly IWebHostEnvironment env; + + /// + /// Initializes a new instance of the class. + /// + /// The context of the database. + /// The execution environment of the web app. + public TestController(AppDbContext dbContext, IWebHostEnvironment env) + { + this.dbContext = dbContext; + this.env = env; + } + + /// + /// Uploads DLL files, runs MyNUnit tests, and saves the results in the database. + /// + /// The uploaded DLL files. + /// Information about the test run. + [HttpPost("run")] + public async Task RunTests([FromForm] IFormFileCollection files) + { + if (!files.Any()) + return BadRequest("No files uploaded."); + + var tempFolder = Path.Combine(env.ContentRootPath, "TempUploads"); + Directory.CreateDirectory(tempFolder); + + var testRuns = new List(); + + foreach (var file in files) + { + var filePath = Path.Combine(tempFolder, file.FileName); + await using (var stream = System.IO.File.Create(filePath)) + { + await file.CopyToAsync(stream); + } + + var testClasses = TestDiscovery.DiscoverFromDll(filePath); + + if (!testClasses.Any()) + continue; + + var results = TestRunner.Run(testClasses); + + var testRun = new TestRun + { + StartedAt = DateTime.UtcNow, + Passed = results.Count(r => r.Status == TestStatus.Passed), + Failed = results.Count(r => r.Status == TestStatus.Failed), + Ignored = results.Count(r => r.Status == TestStatus.Ignored), + Results = results.Select(r => new TestResult + { + TestName = r.TestName, + Status = r.Status, + DurationMs = r.Duration.TotalMilliseconds, + Message = r.Message, + }).ToList(), + }; + + dbContext.TestRuns.Add(testRun); + testRuns.Add(testRun); + } + + await dbContext.SaveChangesAsync(); + + if (!testRuns.Any()) + return BadRequest("No tests found in uploaded assemblies."); + + return Ok(testRuns); + } + + /// + /// Returns all test runs with their results. + /// + /// A representing the asynchronous operation. + [HttpGet("history")] + public async Task GetHistory() + { + var runs = await dbContext.TestRuns + .Include(r => r.Results) + .OrderByDescending(r => r.StartedAt) + .ToListAsync(); + + return Ok(runs); + } +} diff --git a/HW6/MyNunitWeb.Api/TestResult.cs b/HW6/MyNunitWeb.Api/TestResult.cs index 18b3975..4967a7b 100644 --- a/HW6/MyNunitWeb.Api/TestResult.cs +++ b/HW6/MyNunitWeb.Api/TestResult.cs @@ -1,11 +1,12 @@ -// -// Copyright (c) MyNunitWeb. All rights reserved. +// +// Copyright (c) khusainovilas. All rights reserved. // namespace MyNunitWeb.Api; using MyNUnit; using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; /// /// Represents a single test execution result. @@ -15,22 +16,26 @@ public class TestResult /// /// Gets or sets the identifier of the test result. /// + [Key] public int Id { get; set; } /// /// Gets or sets the test name. /// + [Required] [MaxLength(128)] public string TestName { get; set; } = string.Empty; /// /// Gets or sets the execution status of the test. /// + [Required] public TestStatus Status { get; set; } /// /// Gets or sets the test duration in milliseconds. /// + [Required] public double DurationMs { get; set; } /// @@ -42,5 +47,11 @@ public class TestResult /// /// Gets or sets the related test run identifier. /// + [ForeignKey("TestRun")] public int TestRunId { get; set; } + + /// + /// Navigation property to the related test run. + /// + public TestRun? TestRun { get; set; } } \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/TestRun.cs b/HW6/MyNunitWeb.Api/TestRun.cs index d37ec0a..7cd04ad 100644 --- a/HW6/MyNunitWeb.Api/TestRun.cs +++ b/HW6/MyNunitWeb.Api/TestRun.cs @@ -1,9 +1,11 @@ -// -// Copyright (c) MyNunitWeb. All rights reserved. +// +// Copyright (c) khusainovilas. All rights reserved. // namespace MyNunitWeb.Api; +using System.ComponentModel.DataAnnotations; + /// /// Represents a single execution of a test assembly. /// @@ -12,26 +14,31 @@ public class TestRun /// /// Gets or sets the identifier of the test run. /// + [Key] public int Id { get; set; } /// /// Gets or sets the start time of the test run. /// + [Required] public DateTime StartedAt { get; set; } /// /// Gets or sets the number of passed tests. /// + [Required] public int Passed { get; set; } /// /// Gets or sets the number of failed tests. /// + [Required] public int Failed { get; set; } /// /// Gets or sets the number of ignored tests. /// + [Required] public int Ignored { get; set; } /// diff --git a/HW6/MyNunitWeb.Client/app.js b/HW6/MyNunitWeb.Client/app.js new file mode 100644 index 0000000..01ac6ff --- /dev/null +++ b/HW6/MyNunitWeb.Client/app.js @@ -0,0 +1,146 @@ +let dlls = []; +let selectedDlls = []; +let tests = []; + +// DOM элементы +const dllListEl = document.querySelector('.dlls__list'); +const historyListEl = document.querySelector('.history__list'); +const addDllBtn = document.querySelector('.dlls__btn--add'); +const runSelectedBtn = document.querySelector('.dlls__btn--run-selected'); +const runAllBtn = document.querySelector('.dlls__btn--run-all'); +const dllInput = document.querySelector('.dlls__input'); +const clearHistoryBtn = document.querySelector('.history__btn--clear'); + +// Добавление DLL +addDllBtn.addEventListener('click', () => dllInput.click()); +dllInput.addEventListener('change', e => { + const files = Array.from(e.target.files); // получаем массив выбранных файлов + files.forEach(file => { + if (!dlls.includes(file.name)) { + dlls.push(file.name); // добавляем только уникальные + } + if (!selectedDlls.includes(file.name)) selectedDlls.push(file.name); + }); + + renderDlls(); + e.target.value = null; // чтобы можно было выбрать те же файлы снова +}); + +// Очистка истории +clearHistoryBtn.addEventListener('click', () => { + tests = []; + renderHistory(); +}); + +// Запуск выбранных DLL +runSelectedBtn.addEventListener('click', () => { + runTests(selectedDlls); +}); + +// Запуск всех DLL +runAllBtn.addEventListener('click', () => { + runTests(dlls); +}); + +// Отображение DLL +function renderDlls() { + dllListEl.innerHTML = ''; + dlls.forEach(dll => { + const li = document.createElement('li'); + li.textContent = dll; + if (selectedDlls.includes(dll)) li.classList.add('dlls__item--selected'); + + li.addEventListener('click', () => { + if (selectedDlls.includes(dll)) { + selectedDlls = selectedDlls.filter(d => d !== dll); + } else { + selectedDlls.push(dll); + } + renderDlls(); + }); + + dllListEl.appendChild(li); + }); + + runSelectedBtn.disabled = selectedDlls.length === 0; + runAllBtn.disabled = dlls.length === 0; +} + +// Симуляция запуска тестов +function runTests(dllArray) { + dllArray.forEach(dll => { + const testResult = { + name: dll, + status: Math.random() > 0.3 ? 'Passed' : 'Failed', + time: (Math.random() * 0.5 + 0.01).toFixed(2) + 's', + error: null + }; + if (testResult.status === 'Failed') { + testResult.error = 'Simulated error for ' + dll; + } + tests.push(testResult); + }); + + renderHistory(); +} + +// Рендер истории +function renderHistory() { + historyListEl.innerHTML = ''; + + tests.forEach((test, index) => { + const li = document.createElement('li'); + if (index === tests.length - 1) li.classList.add('history__item--latest'); + + li.textContent = `${test.name} - ${test.status} (${test.time})`; + + if (test.status === 'Failed') { + const errorDiv = document.createElement('div'); + errorDiv.textContent = test.error; + errorDiv.classList.add('history__error'); + li.appendChild(errorDiv); + } + + historyListEl.appendChild(li); + }); +} + +function renderDlls() { + dllListEl.innerHTML = ''; + dlls.forEach(dll => { + const li = document.createElement('li'); + li.classList.add('mynunit__dll-item'); + li.textContent = dll; + + if (selectedDlls.includes(dll)) li.classList.add('selected'); + + // Клик по элементу для выбора/снятия выделения + li.addEventListener('click', () => { + if (selectedDlls.includes(dll)) { + selectedDlls = selectedDlls.filter(d => d !== dll); + } else { + selectedDlls.push(dll); + } + renderDlls(); + }); + + // Кнопка удаления + const removeBtn = document.createElement('button'); + removeBtn.textContent = '✕'; + removeBtn.classList.add('mynunit__dll-remove-btn'); + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + dlls = dlls.filter(d => d !== dll); + selectedDlls = selectedDlls.filter(d => d !== dll); + renderDlls(); + }); + + li.appendChild(removeBtn); + dllListEl.appendChild(li); + }); +} + + +// Инициализация +renderDlls(); +renderHistory(); diff --git a/HW6/MyNunitWeb.Client/index.html b/HW6/MyNunitWeb.Client/index.html new file mode 100644 index 0000000..0867ab3 --- /dev/null +++ b/HW6/MyNunitWeb.Client/index.html @@ -0,0 +1,39 @@ + + + + + + MyNUnit Web + + + +
+

MyNUnit

+
+ +
+
+ + + + +
+

Test History

+ +
    +
    +
    +
    + + + + diff --git a/HW6/MyNunitWeb.Client/style.css b/HW6/MyNunitWeb.Client/style.css new file mode 100644 index 0000000..c1b8988 --- /dev/null +++ b/HW6/MyNunitWeb.Client/style.css @@ -0,0 +1,154 @@ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; +} + +.header { + background-color: #1976d2; + color: white; + padding: 10px 20px; +} + +.header__title { + margin: 0; +} + +.main { + padding: 10px 20px; +} + +.main__content { + display: flex; + gap: 20px; + margin-top: 20px; +} + +/* DLLs */ +.dlls { + width: 25%; + background: #f5f5f5; + padding: 10px; + border-radius: 5px; + display: flex; + flex-direction: column; +} + +.dlls__actions { + display: flex; + flex-direction: column; + gap: 5px; + margin-bottom: 10px; +} + +.dlls__btn { + padding: 5px; + cursor: pointer; +} + +.dlls__list { + list-style: none; + padding: 0; + flex-grow: 1; + overflow-y: auto; +} + +.dlls__list li { + background: white; + margin-bottom: 5px; + padding: 8px; + border-radius: 3px; + cursor: pointer; +} + +.dlls__list li.dlls__item--selected { + background: #c8e6c9; +} + +/* History */ +.history { + width: 75%; + background: #f5f5f5; + padding: 10px; + border-radius: 5px; + display: flex; + flex-direction: column; + max-height: 80vh; + overflow-y: auto; +} + +.history__list { + list-style: none; + padding: 0; + flex-grow: 1; +} + +.history__list li { + background: white; + margin-bottom: 5px; + padding: 8px; + border-radius: 3px; + cursor: pointer; +} + +.history__list li.history__item--latest { + background: #e3f2fd; +} + +.history__list li .history__error { + color: red; + font-weight: bold; + margin-top: 5px; +} + +.btn { + background-color: #1976d2; + color: white; + border: none; + padding: 12px 24px; + font-size: 16px; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.3s, transform 0.2s; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.btn:hover { + background-color: #115293; + transform: translateY(-2px); +} + +.mynunit__dll-item { + display: flex; + align-items: center; + position: relative; + padding: 5px; + border-bottom: 1px solid #eee; + cursor: pointer; +} + +.mynunit__dll-item.selected { + background-color: #def; +} + +.mynunit__dll-item:hover .mynunit__dll-remove-btn { + display: inline-block; +} + +.mynunit__dll-remove-btn { + display: none; + position: absolute; + right: 5px; + background-color: #f44336; + color: white; + border: none; + padding: 2px 6px; + cursor: pointer; + border-radius: 50%; + font-size: 12px; +} + +.mynunit__dll-remove-btn:hover { + background-color: #d32f2f; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} \ No newline at end of file From 95d79c94202c42cf423cc75713600de88374b5a8 Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Wed, 31 Dec 2025 22:07:56 +0300 Subject: [PATCH 06/10] refactor entire proj --- HW6/MyNUnit.SampleTests/Calculator.cs | 46 +++++ HW6/MyNUnit.SampleTests/CalculatorTests.cs | 75 +++++++ .../MyNUnit.SampleTests.csproj | 13 ++ HW6/MyNunitWeb.Api/.gitignore | 18 ++ .../Controllers/TestController.cs | 175 ++++++++++++++++ ... 20251231185953_InitialCreate.Designer.cs} | 66 +++--- ...ate.cs => 20251231185953_InitialCreate.cs} | 46 ++--- ...ot.cs => MyNUnitDbContextModelSnapshot.cs} | 66 +++--- HW6/MyNunitWeb.Api/Models/Run.cs | 31 +++ HW6/MyNunitWeb.Api/Models/TestRunResult.cs | 48 +++++ HW6/MyNunitWeb.Api/MyNUnitDbContext.cs | 45 ++++ HW6/MyNunitWeb.Api/Program.cs | 17 +- .../Properties/launchSettings.json | 23 +++ HW6/MyNunitWeb.Client/app.js | 146 ------------- HW6/MyNunitWeb.Client/index.html | 78 ++++--- HW6/MyNunitWeb.Client/script.js | 107 ++++++++++ HW6/MyNunitWeb.Client/style.css | 195 +++++++----------- 17 files changed, 782 insertions(+), 413 deletions(-) create mode 100644 HW6/MyNUnit.SampleTests/Calculator.cs create mode 100644 HW6/MyNUnit.SampleTests/CalculatorTests.cs create mode 100644 HW6/MyNUnit.SampleTests/MyNUnit.SampleTests.csproj create mode 100644 HW6/MyNunitWeb.Api/.gitignore create mode 100644 HW6/MyNunitWeb.Api/Controllers/TestController.cs rename HW6/MyNunitWeb.Api/Migrations/{20251228190820_InitialCreate.Designer.cs => 20251231185953_InitialCreate.Designer.cs} (54%) rename HW6/MyNunitWeb.Api/Migrations/{20251228190820_InitialCreate.cs => 20251231185953_InitialCreate.cs} (52%) rename HW6/MyNunitWeb.Api/Migrations/{AppDbContextModelSnapshot.cs => MyNUnitDbContextModelSnapshot.cs} (52%) create mode 100644 HW6/MyNunitWeb.Api/Models/Run.cs create mode 100644 HW6/MyNunitWeb.Api/Models/TestRunResult.cs create mode 100644 HW6/MyNunitWeb.Api/MyNUnitDbContext.cs create mode 100644 HW6/MyNunitWeb.Api/Properties/launchSettings.json delete mode 100644 HW6/MyNunitWeb.Client/app.js create mode 100644 HW6/MyNunitWeb.Client/script.js diff --git a/HW6/MyNUnit.SampleTests/Calculator.cs b/HW6/MyNUnit.SampleTests/Calculator.cs new file mode 100644 index 0000000..6b46f3f --- /dev/null +++ b/HW6/MyNUnit.SampleTests/Calculator.cs @@ -0,0 +1,46 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNUnit.SampleTests; + +/// +/// Simple calculator class. +/// +public static class Calculator +{ + /// + /// Adds two numbers. + /// + /// The first number. + /// The second number. + /// The sum of a and b. + public static int Add(int a, int b) => a + b; + + /// + /// Subtracts second number from first. + /// + /// The number to subtract from. + /// The number to subtract. + /// The result of a minus b. + public static int Subtract(int a, int b) => a - b; + + /// + /// Multiplies two numbers. + /// + /// The first number. + /// The second number. + /// The product of a and b. + public static int Multiply(int a, int b) => a * b; + + /// + /// Divides first number by second. + /// + /// The dividend. + /// The divisor. + /// The result of a divided by b. + public static int Divide(int a, int b) + { + return b == 0 ? throw new DivideByZeroException() : a / b; + } +} \ No newline at end of file diff --git a/HW6/MyNUnit.SampleTests/CalculatorTests.cs b/HW6/MyNUnit.SampleTests/CalculatorTests.cs new file mode 100644 index 0000000..95e6af6 --- /dev/null +++ b/HW6/MyNUnit.SampleTests/CalculatorTests.cs @@ -0,0 +1,75 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNUnit.SampleTests; + +using System; + +/// +/// Tests for the class using MyNUnit. +/// +public class CalculatorTests +{ + /// + /// Checks the addition of two numbers. + /// + [Test] + public void Calculator_Add_ShouldReturnSum() + { + var result = Calculator.Add(2, 3); + if (result != 5) + { + throw new InvalidOperationException($"Expected 5, but got {result}"); + } + } + + /// + /// Checks the subtraction of two numbers. + /// + [Test] + public void Calculator_Subtract_ShouldReturnDifference() + { + var result = Calculator.Subtract(10, 4); + if (result != 6) + { + throw new InvalidOperationException($"Expected 6, but got {result}"); + } + } + + /// + /// Checks division by zero (should throw an exception). + /// + [Test(Expected = typeof(DivideByZeroException))] + public void Calculator_Divide_ByZero_ShouldThrow() + { + Calculator.Divide(10, 0); + } + + /// + /// Ignored test. + /// + [Test] + [Ignore("Example of a ignored test")] + public void Calculator_Multiply_ShouldBeIgnored() + { + var result = Calculator.Multiply(3, 4); + if (result != 12) + { + throw new InvalidOperationException($"Expected 12, but got {result}"); + } + } + + /// + /// A special test that intentionally crashes. + /// + [Test] + public void Calculator_FailedTest_ShouldFail() + { + var result = Calculator.Add(1, 1); + if (result != 3) + { + throw new InvalidOperationException("This test is supposed to fail"); + } + } +} \ No newline at end of file diff --git a/HW6/MyNUnit.SampleTests/MyNUnit.SampleTests.csproj b/HW6/MyNUnit.SampleTests/MyNUnit.SampleTests.csproj new file mode 100644 index 0000000..ef52157 --- /dev/null +++ b/HW6/MyNUnit.SampleTests/MyNUnit.SampleTests.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/HW6/MyNunitWeb.Api/.gitignore b/HW6/MyNunitWeb.Api/.gitignore new file mode 100644 index 0000000..ac22765 --- /dev/null +++ b/HW6/MyNunitWeb.Api/.gitignore @@ -0,0 +1,18 @@ +bin/ +obj/ +*.user +*.suo +*.userprefs + +*.db +*.db-shm +*.db-wal + +uploads/ +TempUploads/ + +.vscode/ + +.idea/ + +*.log \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/Controllers/TestController.cs b/HW6/MyNunitWeb.Api/Controllers/TestController.cs new file mode 100644 index 0000000..2a9a85f --- /dev/null +++ b/HW6/MyNunitWeb.Api/Controllers/TestController.cs @@ -0,0 +1,175 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNunitWeb.Api.Controllers; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MyNUnit; +using MyNunitWeb.Api.Models; + +/// +/// API controller for running tests. +/// +[ApiController] +[Route("api/[controller]")] +public class TestController : ControllerBase +{ + private readonly IWebHostEnvironment env; + private readonly MyNUnitDbContext db; + + /// + /// Initializes a new instance of the class. + /// + /// The web hosting environment. + /// The database context for MyNUnit. + public TestController(IWebHostEnvironment env, MyNUnitDbContext db) + { + this.env = env; + this.db = db; + } + + /// + /// Uploads assemblies and runs tests in them. + /// + /// Array of uploaded DLL files. + /// Returns a JSON object containing the run ID, timestamp, assembly names, and results. + [HttpPost("run")] + public async Task Run([FromForm] IFormFile[]? files) + { + if (files == null || files.Length == 0) + { + return this.BadRequest("No files"); + } + + var runId = Guid.NewGuid().ToString(); + var uploadDir = Path.Combine(this.env.ContentRootPath, "uploads", runId); + Directory.CreateDirectory(uploadDir); + + var assemblyPaths = new List(); + var assemblyNames = new List(); + + foreach (var file in files) + { + if (file.FileName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + var path = Path.Combine(uploadDir, file.FileName); + await using var stream = System.IO.File.Create(path); + await file.CopyToAsync(stream); + assemblyPaths.Add(path); + assemblyNames.Add(file.FileName); + } + } + + if (assemblyPaths.Count == 0) + { + return this.BadRequest("There is no DLL"); + } + + var testClasses = new List(); + foreach (var path in assemblyPaths) + { + try + { + var classes = TestDiscovery.DiscoverFromDll(path); + testClasses.AddRange(classes); + } + catch + { + } + } + + if (testClasses.Count == 0) + { + return this.BadRequest("There are no tests"); + } + + var results = TestRunner.Run(testClasses); + + var run = new Run + { + AssemblyNames = string.Join(", ", assemblyNames), + Results = results.Select(r => new TestRunResult + { + TestName = r.TestName, + Status = r.Status, + Duration = r.Duration, + Message = r.Message, + }).ToList(), + }; + + this.db.Runs.Add(run); + await this.db.SaveChangesAsync(); + + return this.Ok(new + { + id = run.Id, + timestamp = run.Timestamp, + assemblyNames = run.AssemblyNames, + results = run.Results.Select(r => new + { + testName = r.TestName, + status = r.Status.ToString(), + duration = r.Duration.TotalMilliseconds, + message = r.Message ?? string.Empty, + }), + }); + } + + /// + /// Retrieves the history of all test runs. + /// + /// Returns a list of test runs including counts of passed, failed, and ignored tests. + [HttpGet("history")] + public IActionResult History() + { + var runs = this.db.Runs + .Include(r => r.Results) + .OrderByDescending(r => r.Timestamp) + .Select(r => new + { + id = r.Id, + timestamp = r.Timestamp, + assemblyNames = r.AssemblyNames, + passed = r.Results.Count(x => x.Status == TestStatus.Passed), + failed = r.Results.Count(x => x.Status == TestStatus.Failed), + ignored = r.Results.Count(x => x.Status == TestStatus.Ignored), + }) + .ToList(); + + return this.Ok(runs); + } + + /// + /// Retrieves details of a specific test run by ID. + /// + /// The ID of the test run. + /// Returns a JSON object with run details and results. + [HttpGet("run/{id}")] + public IActionResult GetRun(int id) + { + var run = this.db.Runs + .Include(r => r.Results) + .FirstOrDefault(r => r.Id == id); + + if (run == null) + { + return this.NotFound(); + } + + return this.Ok(new + { + id = run.Id, + timestamp = run.Timestamp, + assemblyNames = run.AssemblyNames, + results = run.Results.Select(r => new + { + testName = r.TestName, + status = r.Status.ToString(), + duration = r.Duration.TotalMilliseconds, + message = r.Message ?? string.Empty, + }), + }); + } +} \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.Designer.cs b/HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.Designer.cs similarity index 54% rename from HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.Designer.cs rename to HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.Designer.cs index 1322cc2..095c6ee 100644 --- a/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.Designer.cs +++ b/HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.Designer.cs @@ -10,8 +10,8 @@ namespace MyNunitWeb.Api.Migrations { - [DbContext(typeof(AppDbContext))] - [Migration("20251228190820_InitialCreate")] + [DbContext(typeof(MyNUnitDbContext))] + [Migration("20251231185953_InitialCreate")] partial class InitialCreate { /// @@ -20,83 +20,65 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); - modelBuilder.Entity("MyNunitWeb.Api.TestResult", b => + modelBuilder.Entity("MyNunitWeb.Api.Models.Run", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("DurationMs") - .HasColumnType("REAL"); - - b.Property("Message") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TestName") + b.Property("AssemblyNames") .IsRequired() - .HasMaxLength(128) .HasColumnType("TEXT"); - b.Property("TestRunId") - .HasColumnType("INTEGER"); - - b.Property("TestRunId1") - .HasColumnType("INTEGER"); + b.Property("Timestamp") + .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("TestRunId"); - - b.HasIndex("TestRunId1"); - - b.ToTable("TestResults"); + b.ToTable("Runs"); }); - modelBuilder.Entity("MyNunitWeb.Api.TestRun", b => + modelBuilder.Entity("MyNunitWeb.Api.Models.TestRunResult", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("Failed") - .HasColumnType("INTEGER"); + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); - b.Property("Ignored") + b.Property("RunId") .HasColumnType("INTEGER"); - b.Property("Passed") + b.Property("Status") .HasColumnType("INTEGER"); - b.Property("StartedAt") + b.Property("TestName") + .IsRequired() .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("StartedAt"); + b.HasIndex("RunId"); - b.ToTable("TestRuns"); + b.ToTable("TestResults"); }); - modelBuilder.Entity("MyNunitWeb.Api.TestResult", b => + modelBuilder.Entity("MyNunitWeb.Api.Models.TestRunResult", b => { - b.HasOne("MyNunitWeb.Api.TestRun", null) + b.HasOne("MyNunitWeb.Api.Models.Run", "Run") .WithMany("Results") - .HasForeignKey("TestRunId") + .HasForeignKey("RunId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("MyNunitWeb.Api.TestRun", "TestRun") - .WithMany() - .HasForeignKey("TestRunId1"); - - b.Navigation("TestRun"); + b.Navigation("Run"); }); - modelBuilder.Entity("MyNunitWeb.Api.TestRun", b => + modelBuilder.Entity("MyNunitWeb.Api.Models.Run", b => { b.Navigation("Results"); }); diff --git a/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.cs b/HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.cs similarity index 52% rename from HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.cs rename to HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.cs index 830b04a..3f4ebec 100644 --- a/HW6/MyNunitWeb.Api/Migrations/20251228190820_InitialCreate.cs +++ b/HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.cs @@ -12,19 +12,17 @@ public partial class InitialCreate : Migration protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.CreateTable( - name: "TestRuns", + name: "Runs", columns: table => new { Id = table.Column(type: "INTEGER", nullable: false) .Annotation("Sqlite:Autoincrement", true), - StartedAt = table.Column(type: "TEXT", nullable: false), - Passed = table.Column(type: "INTEGER", nullable: false), - Failed = table.Column(type: "INTEGER", nullable: false), - Ignored = table.Column(type: "INTEGER", nullable: false) + Timestamp = table.Column(type: "TEXT", nullable: false), + AssemblyNames = table.Column(type: "TEXT", nullable: false) }, constraints: table => { - table.PrimaryKey("PK_TestRuns", x => x.Id); + table.PrimaryKey("PK_Runs", x => x.Id); }); migrationBuilder.CreateTable( @@ -33,43 +31,27 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column(type: "INTEGER", nullable: false) .Annotation("Sqlite:Autoincrement", true), - TestName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + TestName = table.Column(type: "TEXT", nullable: false), Status = table.Column(type: "INTEGER", nullable: false), - DurationMs = table.Column(type: "REAL", nullable: false), - Message = table.Column(type: "TEXT", maxLength: 256, nullable: true), - TestRunId = table.Column(type: "INTEGER", nullable: false), - TestRunId1 = table.Column(type: "INTEGER", nullable: true) + Duration = table.Column(type: "TEXT", nullable: false), + Message = table.Column(type: "TEXT", nullable: true), + RunId = table.Column(type: "INTEGER", nullable: false) }, constraints: table => { table.PrimaryKey("PK_TestResults", x => x.Id); table.ForeignKey( - name: "FK_TestResults_TestRuns_TestRunId", - column: x => x.TestRunId, - principalTable: "TestRuns", + name: "FK_TestResults_Runs_RunId", + column: x => x.RunId, + principalTable: "Runs", principalColumn: "Id", onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_TestResults_TestRuns_TestRunId1", - column: x => x.TestRunId1, - principalTable: "TestRuns", - principalColumn: "Id"); }); migrationBuilder.CreateIndex( - name: "IX_TestResults_TestRunId", - table: "TestResults", - column: "TestRunId"); - - migrationBuilder.CreateIndex( - name: "IX_TestResults_TestRunId1", + name: "IX_TestResults_RunId", table: "TestResults", - column: "TestRunId1"); - - migrationBuilder.CreateIndex( - name: "IX_TestRuns_StartedAt", - table: "TestRuns", - column: "StartedAt"); + column: "RunId"); } /// @@ -79,7 +61,7 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "TestResults"); migrationBuilder.DropTable( - name: "TestRuns"); + name: "Runs"); } } } diff --git a/HW6/MyNunitWeb.Api/Migrations/AppDbContextModelSnapshot.cs b/HW6/MyNunitWeb.Api/Migrations/MyNUnitDbContextModelSnapshot.cs similarity index 52% rename from HW6/MyNunitWeb.Api/Migrations/AppDbContextModelSnapshot.cs rename to HW6/MyNunitWeb.Api/Migrations/MyNUnitDbContextModelSnapshot.cs index 4fffff3..1492075 100644 --- a/HW6/MyNunitWeb.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/HW6/MyNunitWeb.Api/Migrations/MyNUnitDbContextModelSnapshot.cs @@ -9,91 +9,73 @@ namespace MyNunitWeb.Api.Migrations { - [DbContext(typeof(AppDbContext))] - partial class AppDbContextModelSnapshot : ModelSnapshot + [DbContext(typeof(MyNUnitDbContext))] + partial class MyNUnitDbContextModelSnapshot : ModelSnapshot { protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "9.0.11"); - modelBuilder.Entity("MyNunitWeb.Api.TestResult", b => + modelBuilder.Entity("MyNunitWeb.Api.Models.Run", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("DurationMs") - .HasColumnType("REAL"); - - b.Property("Message") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("Status") - .HasColumnType("INTEGER"); - - b.Property("TestName") + b.Property("AssemblyNames") .IsRequired() - .HasMaxLength(128) .HasColumnType("TEXT"); - b.Property("TestRunId") - .HasColumnType("INTEGER"); - - b.Property("TestRunId1") - .HasColumnType("INTEGER"); + b.Property("Timestamp") + .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("TestRunId"); - - b.HasIndex("TestRunId1"); - - b.ToTable("TestResults"); + b.ToTable("Runs"); }); - modelBuilder.Entity("MyNunitWeb.Api.TestRun", b => + modelBuilder.Entity("MyNunitWeb.Api.Models.TestRunResult", b => { b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("INTEGER"); - b.Property("Failed") - .HasColumnType("INTEGER"); + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); - b.Property("Ignored") + b.Property("RunId") .HasColumnType("INTEGER"); - b.Property("Passed") + b.Property("Status") .HasColumnType("INTEGER"); - b.Property("StartedAt") + b.Property("TestName") + .IsRequired() .HasColumnType("TEXT"); b.HasKey("Id"); - b.HasIndex("StartedAt"); + b.HasIndex("RunId"); - b.ToTable("TestRuns"); + b.ToTable("TestResults"); }); - modelBuilder.Entity("MyNunitWeb.Api.TestResult", b => + modelBuilder.Entity("MyNunitWeb.Api.Models.TestRunResult", b => { - b.HasOne("MyNunitWeb.Api.TestRun", null) + b.HasOne("MyNunitWeb.Api.Models.Run", "Run") .WithMany("Results") - .HasForeignKey("TestRunId") + .HasForeignKey("RunId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("MyNunitWeb.Api.TestRun", "TestRun") - .WithMany() - .HasForeignKey("TestRunId1"); - - b.Navigation("TestRun"); + b.Navigation("Run"); }); - modelBuilder.Entity("MyNunitWeb.Api.TestRun", b => + modelBuilder.Entity("MyNunitWeb.Api.Models.Run", b => { b.Navigation("Results"); }); diff --git a/HW6/MyNunitWeb.Api/Models/Run.cs b/HW6/MyNunitWeb.Api/Models/Run.cs new file mode 100644 index 0000000..fe38b60 --- /dev/null +++ b/HW6/MyNunitWeb.Api/Models/Run.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNunitWeb.Api.Models; + +/// +/// Represents a test run containing metadata and the results of executed tests. +/// +public class Run +{ + /// + /// Gets or sets the unique identifier of the run. + /// + public int Id { get; set; } + + /// + /// Gets or sets the timestamp when the run was created. + /// + public DateTime Timestamp { get; set; } = DateTime.Now; + + /// + /// Gets or sets the names of the assemblies. + /// + public string AssemblyNames { get; set; } = string.Empty; + + /// + /// Gets or sets the list of test results for this run. + /// + public List Results { get; set; } = new (); +} \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/Models/TestRunResult.cs b/HW6/MyNunitWeb.Api/Models/TestRunResult.cs new file mode 100644 index 0000000..6801e7d --- /dev/null +++ b/HW6/MyNunitWeb.Api/Models/TestRunResult.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNunitWeb.Api.Models; + +using MyNUnit; + +/// +/// Represents the result of a single test within a test run. +/// +public class TestRunResult +{ + /// + /// Gets or sets the unique identifier of the test result. + /// + public int Id { get; set; } + + /// + /// Gets or sets the fully qualified name of the test. + /// + public string TestName { get; set; } = string.Empty; + + /// + /// Gets or sets the status of the test (Passed, Failed, Ignored). + /// + public TestStatus Status { get; set; } + + /// + /// Gets or sets the duration of the test execution. + /// + public TimeSpan Duration { get; set; } + + /// + /// Gets or sets the message associated with the test result. + /// + public string? Message { get; set; } + + /// + /// Gets or sets the foreign key of the related . + /// + public int RunId { get; set; } + + /// + /// Gets or sets the related object. + /// + public Run? Run { get; set; } +} diff --git a/HW6/MyNunitWeb.Api/MyNUnitDbContext.cs b/HW6/MyNunitWeb.Api/MyNUnitDbContext.cs new file mode 100644 index 0000000..2c85eb3 --- /dev/null +++ b/HW6/MyNunitWeb.Api/MyNUnitDbContext.cs @@ -0,0 +1,45 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace MyNunitWeb.Api; + +using Microsoft.EntityFrameworkCore; +using MyNunitWeb.Api.Models; + +/// +/// Represents the Entity Framework Core database context for MyNUnit. +/// +public class MyNUnitDbContext : DbContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The options to configure the context. + public MyNUnitDbContext(DbContextOptions options) + : base(options) + { + } + + /// + /// Gets or sets the DbSet of test runs. + /// + public DbSet Runs { get; set; } = null!; + + /// + /// Gets or sets the DbSet of individual test results. + /// + public DbSet TestResults { get; set; } = null!; + + /// + /// Configures the EF Core model, including relationships. + /// + /// The model builder used to configure entity mappings. + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasOne(tr => tr.Run) + .WithMany(r => r.Results) + .HasForeignKey(tr => tr.RunId); + } +} diff --git a/HW6/MyNunitWeb.Api/Program.cs b/HW6/MyNunitWeb.Api/Program.cs index 54a2ba2..5e5a10f 100644 --- a/HW6/MyNunitWeb.Api/Program.cs +++ b/HW6/MyNunitWeb.Api/Program.cs @@ -7,14 +7,25 @@ var builder = WebApplication.CreateBuilder(args); -builder.Services.AddDbContext(options => - options.UseSqlite("Data Source=MyNUnit.db")); +builder.Services.AddCors(options => +{ + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); +}); + +builder.Services.AddDbContext(options => + options.UseSqlite("Data Source=mynunit.db")); builder.Services.AddControllers(); var app = builder.Build(); +app.UseCors("AllowAll"); app.UseHttpsRedirection(); app.MapControllers(); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/Properties/launchSettings.json b/HW6/MyNunitWeb.Api/Properties/launchSettings.json new file mode 100644 index 0000000..f91a8f9 --- /dev/null +++ b/HW6/MyNunitWeb.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5091", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7298;http://localhost:5091", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/HW6/MyNunitWeb.Client/app.js b/HW6/MyNunitWeb.Client/app.js deleted file mode 100644 index 01ac6ff..0000000 --- a/HW6/MyNunitWeb.Client/app.js +++ /dev/null @@ -1,146 +0,0 @@ -let dlls = []; -let selectedDlls = []; -let tests = []; - -// DOM элементы -const dllListEl = document.querySelector('.dlls__list'); -const historyListEl = document.querySelector('.history__list'); -const addDllBtn = document.querySelector('.dlls__btn--add'); -const runSelectedBtn = document.querySelector('.dlls__btn--run-selected'); -const runAllBtn = document.querySelector('.dlls__btn--run-all'); -const dllInput = document.querySelector('.dlls__input'); -const clearHistoryBtn = document.querySelector('.history__btn--clear'); - -// Добавление DLL -addDllBtn.addEventListener('click', () => dllInput.click()); -dllInput.addEventListener('change', e => { - const files = Array.from(e.target.files); // получаем массив выбранных файлов - files.forEach(file => { - if (!dlls.includes(file.name)) { - dlls.push(file.name); // добавляем только уникальные - } - if (!selectedDlls.includes(file.name)) selectedDlls.push(file.name); - }); - - renderDlls(); - e.target.value = null; // чтобы можно было выбрать те же файлы снова -}); - -// Очистка истории -clearHistoryBtn.addEventListener('click', () => { - tests = []; - renderHistory(); -}); - -// Запуск выбранных DLL -runSelectedBtn.addEventListener('click', () => { - runTests(selectedDlls); -}); - -// Запуск всех DLL -runAllBtn.addEventListener('click', () => { - runTests(dlls); -}); - -// Отображение DLL -function renderDlls() { - dllListEl.innerHTML = ''; - dlls.forEach(dll => { - const li = document.createElement('li'); - li.textContent = dll; - if (selectedDlls.includes(dll)) li.classList.add('dlls__item--selected'); - - li.addEventListener('click', () => { - if (selectedDlls.includes(dll)) { - selectedDlls = selectedDlls.filter(d => d !== dll); - } else { - selectedDlls.push(dll); - } - renderDlls(); - }); - - dllListEl.appendChild(li); - }); - - runSelectedBtn.disabled = selectedDlls.length === 0; - runAllBtn.disabled = dlls.length === 0; -} - -// Симуляция запуска тестов -function runTests(dllArray) { - dllArray.forEach(dll => { - const testResult = { - name: dll, - status: Math.random() > 0.3 ? 'Passed' : 'Failed', - time: (Math.random() * 0.5 + 0.01).toFixed(2) + 's', - error: null - }; - if (testResult.status === 'Failed') { - testResult.error = 'Simulated error for ' + dll; - } - tests.push(testResult); - }); - - renderHistory(); -} - -// Рендер истории -function renderHistory() { - historyListEl.innerHTML = ''; - - tests.forEach((test, index) => { - const li = document.createElement('li'); - if (index === tests.length - 1) li.classList.add('history__item--latest'); - - li.textContent = `${test.name} - ${test.status} (${test.time})`; - - if (test.status === 'Failed') { - const errorDiv = document.createElement('div'); - errorDiv.textContent = test.error; - errorDiv.classList.add('history__error'); - li.appendChild(errorDiv); - } - - historyListEl.appendChild(li); - }); -} - -function renderDlls() { - dllListEl.innerHTML = ''; - dlls.forEach(dll => { - const li = document.createElement('li'); - li.classList.add('mynunit__dll-item'); - li.textContent = dll; - - if (selectedDlls.includes(dll)) li.classList.add('selected'); - - // Клик по элементу для выбора/снятия выделения - li.addEventListener('click', () => { - if (selectedDlls.includes(dll)) { - selectedDlls = selectedDlls.filter(d => d !== dll); - } else { - selectedDlls.push(dll); - } - renderDlls(); - }); - - // Кнопка удаления - const removeBtn = document.createElement('button'); - removeBtn.textContent = '✕'; - removeBtn.classList.add('mynunit__dll-remove-btn'); - removeBtn.addEventListener('click', (e) => { - e.stopPropagation(); - dlls = dlls.filter(d => d !== dll); - selectedDlls = selectedDlls.filter(d => d !== dll); - renderDlls(); - }); - - li.appendChild(removeBtn); - dllListEl.appendChild(li); - }); -} - - -// Инициализация -renderDlls(); -renderHistory(); diff --git a/HW6/MyNunitWeb.Client/index.html b/HW6/MyNunitWeb.Client/index.html index 0867ab3..a3c453e 100644 --- a/HW6/MyNunitWeb.Client/index.html +++ b/HW6/MyNunitWeb.Client/index.html @@ -1,39 +1,61 @@ - - - MyNUnit Web - + + + MyNUnit Web + -
    -

    MyNUnit

    -
    +
    +

    MyNUnitWeb

    -
    -
    - - +
    +

    Upload Assemblies and Run Tests

    +
    + + +
    +
    - -
    -

    Test History

    - -
      -
      + + +
      +

      Run History

      +
        +
        + +
        -
        - + diff --git a/HW6/MyNunitWeb.Client/script.js b/HW6/MyNunitWeb.Client/script.js new file mode 100644 index 0000000..d62dfc6 --- /dev/null +++ b/HW6/MyNunitWeb.Client/script.js @@ -0,0 +1,107 @@ +const API_BASE = 'http://localhost:5091/api/test'; + +document.addEventListener('DOMContentLoaded', () => { + + const form = document.getElementById('uploadForm'); + if (!form) { + return; + } + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const formData = new FormData(form); + + try { + const res = await fetch(`${API_BASE}/run`, { + method: 'POST', + body: formData + }); + + if (res.ok) { + const run = await res.json(); + + document.getElementById('currentSummary').textContent = + `${new Date(run.timestamp).toLocaleString()} — ${run.assemblyNames}`; + + fillTable('currentTable', run.results); + document.getElementById('currentResults').style.display = 'block'; + + loadHistory(); + form.reset(); + } else { + const errorText = await res.text(); + console.error('Server error:', res.status, errorText); + alert('Failed to run tests: ' + res.status); + } + } catch (err) { + console.error('Network error:', err); + alert('Unable to connect to the server. Make sure dotnet run is running.'); + } + }); + + loadHistory(); +}); + +async function loadHistory() { + try { + const res = await fetch(`${API_BASE}/history`); + if (!res.ok) throw new Error('Failed to load history'); + const runs = await res.json(); + + const list = document.getElementById('historyList'); + list.innerHTML = ''; + + runs.forEach(run => { + const li = document.createElement('li'); + li.innerHTML = ` + ${new Date(run.timestamp).toLocaleString()}
        + Assemblies: ${run.assemblyNames}
        + Passed: ${run.passed} | Failed: ${run.failed} | Ignored: ${run.ignored} + `; + li.onclick = () => showDetails(run.id); + list.appendChild(li); + }); + } catch (err) { + console.error('Error loading history:', err); + } +} + +async function showDetails(id) { + try { + const res = await fetch(`${API_BASE}/run/${id}`); + const run = await res.json(); + + document.getElementById('detailsSummary').textContent = + `${new Date(run.timestamp).toLocaleString()} — ${run.assemblyNames}`; + + fillTable('detailsTable', run.results); + document.getElementById('details').style.display = 'block'; + } catch (err) { + console.error('Error:', err); + } +} + +function fillTable(tableId, results) { + const tbody = document.querySelector(`#${tableId} tbody`); + tbody.innerHTML = ''; + + results.forEach(r => { + const tr = document.createElement('tr'); + + let time; + if (r.status === 'Ignored') { + time = 0; + } else { + time = r.duration < 0.01 ? '<0.01' : r.duration.toFixed(2); + } + + tr.innerHTML = ` + ${r.testName.split(".").pop()} + ${r.status} + ${time} + ${r.message || ''} + `; + tbody.appendChild(tr); + }); +} \ No newline at end of file diff --git a/HW6/MyNunitWeb.Client/style.css b/HW6/MyNunitWeb.Client/style.css index c1b8988..a8359c3 100644 --- a/HW6/MyNunitWeb.Client/style.css +++ b/HW6/MyNunitWeb.Client/style.css @@ -1,154 +1,109 @@ body { - font-family: Arial, sans-serif; - margin: 0; - padding: 0; + font-family: 'Segoe UI', Arial, sans-serif; + background: #f0f2f5; + color: #333; + margin: 0; + padding: 20px; } -.header { - background-color: #1976d2; - color: white; - padding: 10px 20px; +.app { + max-width: 1200px; + margin: 0 auto; } -.header__title { - margin: 0; +.app__title { + text-align: center; + color: #1a1a1a; + margin-bottom: 40px; } -.main { - padding: 10px 20px; +.card { + padding: 30px; + margin-bottom: 40px; + border-radius: 12px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); } -.main__content { - display: flex; - gap: 20px; - margin-top: 20px; +.card_history .history_list { + max-height: 400px; + overflow-y: auto; + padding-right: 10px; } -/* DLLs */ -.dlls { - width: 25%; - background: #f5f5f5; - padding: 10px; - border-radius: 5px; - display: flex; - flex-direction: column; +.card__title { + margin-top: 0; + color: #2c3e50; + border-bottom: 2px solid #0066cc; + padding-bottom: 10px; } -.dlls__actions { - display: flex; - flex-direction: column; - gap: 5px; - margin-bottom: 10px; -} - -.dlls__btn { - padding: 5px; - cursor: pointer; -} - -.dlls__list { - list-style: none; - padding: 0; - flex-grow: 1; - overflow-y: auto; +.upload_form__input { + display: block; + margin: 20px 0; + padding: 10px; + font-size: 16px; } -.dlls__list li { - background: white; - margin-bottom: 5px; - padding: 8px; - border-radius: 3px; - cursor: pointer; +.upload_form__button { + padding: 14px 32px; + background: #0066cc; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background 0.3s; } -.dlls__list li.dlls__item--selected { - background: #c8e6c9; +.upload_form__button:hover { + background: #0050a0; } -/* History */ -.history { - width: 75%; - background: #f5f5f5; - padding: 10px; - border-radius: 5px; - display: flex; - flex-direction: column; - max-height: 80vh; - overflow-y: auto; +.results_table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; } -.history__list { - list-style: none; - padding: 0; - flex-grow: 1; +.results_table th, +.results_table td { + padding: 14px; + text-align: left; + border-bottom: 1px solid #eee; } -.history__list li { - background: white; - margin-bottom: 5px; - padding: 8px; - border-radius: 3px; - cursor: pointer; +.results_table th { + background: #f8f9fa; + font-weight: 600; } -.history__list li.history__item--latest { - background: #e3f2fd; +.summary { + font-size: 18px; + font-weight: bold; + color: #2c3e50; + margin-bottom: 20px; } -.history__list li .history__error { - color: red; - font-weight: bold; - margin-top: 5px; +.history_list { + list-style: none; + padding: 0; } -.btn { - background-color: #1976d2; - color: white; - border: none; - padding: 12px 24px; - font-size: 16px; - border-radius: 8px; +.history_list li { + padding: 20px; + background: #f8f9fa; + margin: 12px 0; + border-radius: 10px; + border-left: 6px solid #0066cc; cursor: pointer; - transition: background-color 0.3s, transform 0.2s; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); -} - -.btn:hover { - background-color: #115293; - transform: translateY(-2px); -} - -.mynunit__dll-item { - display: flex; - align-items: center; - position: relative; - padding: 5px; - border-bottom: 1px solid #eee; - cursor: pointer; -} - -.mynunit__dll-item.selected { - background-color: #def; -} - -.mynunit__dll-item:hover .mynunit__dll-remove-btn { - display: inline-block; + transition: all 0.2s; } -.mynunit__dll-remove-btn { - display: none; - position: absolute; - right: 5px; - background-color: #f44336; - color: white; - border: none; - padding: 2px 6px; - cursor: pointer; - border-radius: 50%; - font-size: 12px; +.history_list li:hover { + background: #e8f4ff; + transform: translateX(5px); } -.mynunit__dll-remove-btn:hover { - background-color: #d32f2f; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); -} \ No newline at end of file +.passed { color: green; font-weight: bold; } +.failed { color: red; font-weight: bold; } +.ignored { color: orange; font-weight: bold; } \ No newline at end of file From 4b9cda9fdb72c6766c4be47f06f118f1c56316a8 Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Wed, 31 Dec 2025 22:22:21 +0300 Subject: [PATCH 07/10] fix ci --- HW6/MyNUnit/ConsoleReporter.cs | 89 ---------------------------------- HW6/MyNUnit/Program.cs | 39 --------------- 2 files changed, 128 deletions(-) delete mode 100644 HW6/MyNUnit/ConsoleReporter.cs delete mode 100644 HW6/MyNUnit/Program.cs diff --git a/HW6/MyNUnit/ConsoleReporter.cs b/HW6/MyNUnit/ConsoleReporter.cs deleted file mode 100644 index 5f72050..0000000 --- a/HW6/MyNUnit/ConsoleReporter.cs +++ /dev/null @@ -1,89 +0,0 @@ -// -// Copyright (c) khusainovilas. All rights reserved. -// - -namespace MyNUnit; - -using System; -using System.Collections.Generic; -using System.Linq; - -/// -/// Prints test results to console. -/// -public static class ConsoleReporter -{ - /// - /// Prints test execution report. - /// - /// A collection of objects to print. - public static void Print(IEnumerable results) - { - ArgumentNullException.ThrowIfNull(results); - - var list = results.ToList(); - - foreach (var result in list.OrderBy(r => r.TestName)) - { - PrintSingle(result); - } - - PrintSummary(list); - } - - private static void PrintSingle(TestResult result) - { - switch (result.Status) - { - case TestStatus.Passed: - Console.WriteLine( - $"[PASS] {result.TestName} ({Format(result.Duration)})"); - break; - - case TestStatus.Failed: - Console.WriteLine( - $"[FAIL] {result.TestName} ({Format(result.Duration)})"); - - if (!string.IsNullOrWhiteSpace(result.Message)) - { - Console.WriteLine($" {result.Message}"); - } - - if (result.Exception != null) - { - Console.WriteLine( - $" {result.Exception.GetType().Name}: {result.Exception.Message}"); - } - - break; - - case TestStatus.Ignored: - Console.WriteLine($"[IGNORED] {result.TestName}"); - Console.WriteLine($" Reason: {result.Message}"); - break; - } - } - - private static void PrintSummary(IReadOnlyCollection results) - { - Console.WriteLine(); - Console.WriteLine(new string('=', 40)); - - Console.WriteLine($"Total: {results.Count}"); - Console.WriteLine($"Passed: {results.Count(r => r.Status == TestStatus.Passed)}"); - Console.WriteLine($"Failed: {results.Count(r => r.Status == TestStatus.Failed)}"); - Console.WriteLine($"Ignored: {results.Count(r => r.Status == TestStatus.Ignored)}"); - - var totalTime = TimeSpan.FromTicks(results.Sum(r => r.Duration.Ticks)); - Console.WriteLine($"Time: {Format(totalTime)}"); - - Console.WriteLine(new string('=', 40)); - } - - private static string Format(TimeSpan time) - { - return time.TotalMilliseconds < 1 - ? "<1 ms" - : $"{time.TotalMilliseconds:F0} ms"; - } -} diff --git a/HW6/MyNUnit/Program.cs b/HW6/MyNUnit/Program.cs deleted file mode 100644 index 2299290..0000000 --- a/HW6/MyNUnit/Program.cs +++ /dev/null @@ -1,39 +0,0 @@ -// -// Copyright (c) khusainovilas. All rights reserved. -// - -using MyNUnit; - -if (args.Length != 1) -{ - PrintUsage(); - return; -} - -try -{ - var testClasses = TestDiscovery.Discover(args[0]); - - if (testClasses.Count == 0) - { - Console.WriteLine("No tests found."); - return; - } - - var results = TestRunner.Run(testClasses); - ConsoleReporter.Print(results); -} -catch (Exception ex) -{ - Console.WriteLine("[ERROR]"); - Console.WriteLine(ex.Message); -} - -static void PrintUsage() -{ - Console.WriteLine("Usage:"); - Console.WriteLine(" MyNUnit "); - Console.WriteLine(); - Console.WriteLine("Arguments:"); - Console.WriteLine(" Path to directory or .dll file containing tests."); -} From 6f99f933d47e230bd5649949e60c5f4b9771a66c Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Wed, 31 Dec 2025 22:28:01 +0300 Subject: [PATCH 08/10] fix stylecop --- HW6/MyNunitWeb.Api/TestController.cs | 104 --------------------------- HW6/MyNunitWeb.Api/TestResult.cs | 57 --------------- HW6/MyNunitWeb.Api/TestRun.cs | 48 ------------- 3 files changed, 209 deletions(-) delete mode 100644 HW6/MyNunitWeb.Api/TestController.cs delete mode 100644 HW6/MyNunitWeb.Api/TestResult.cs delete mode 100644 HW6/MyNunitWeb.Api/TestRun.cs diff --git a/HW6/MyNunitWeb.Api/TestController.cs b/HW6/MyNunitWeb.Api/TestController.cs deleted file mode 100644 index adc67d9..0000000 --- a/HW6/MyNunitWeb.Api/TestController.cs +++ /dev/null @@ -1,104 +0,0 @@ -// -// Copyright (c) khusainovilas. All rights reserved. -// - -namespace MyNunitWeb.Api; - -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using MyNUnit; - -/// -/// Controller for uploading assemblies and running tests. -/// -[ApiController] -[Route("api/[controller]")] -public class TestController : ControllerBase -{ - private readonly AppDbContext dbContext; - private readonly IWebHostEnvironment env; - - /// - /// Initializes a new instance of the class. - /// - /// The context of the database. - /// The execution environment of the web app. - public TestController(AppDbContext dbContext, IWebHostEnvironment env) - { - this.dbContext = dbContext; - this.env = env; - } - - /// - /// Uploads DLL files, runs MyNUnit tests, and saves the results in the database. - /// - /// The uploaded DLL files. - /// Information about the test run. - [HttpPost("run")] - public async Task RunTests([FromForm] IFormFileCollection files) - { - if (!files.Any()) - return BadRequest("No files uploaded."); - - var tempFolder = Path.Combine(env.ContentRootPath, "TempUploads"); - Directory.CreateDirectory(tempFolder); - - var testRuns = new List(); - - foreach (var file in files) - { - var filePath = Path.Combine(tempFolder, file.FileName); - await using (var stream = System.IO.File.Create(filePath)) - { - await file.CopyToAsync(stream); - } - - var testClasses = TestDiscovery.DiscoverFromDll(filePath); - - if (!testClasses.Any()) - continue; - - var results = TestRunner.Run(testClasses); - - var testRun = new TestRun - { - StartedAt = DateTime.UtcNow, - Passed = results.Count(r => r.Status == TestStatus.Passed), - Failed = results.Count(r => r.Status == TestStatus.Failed), - Ignored = results.Count(r => r.Status == TestStatus.Ignored), - Results = results.Select(r => new TestResult - { - TestName = r.TestName, - Status = r.Status, - DurationMs = r.Duration.TotalMilliseconds, - Message = r.Message, - }).ToList(), - }; - - dbContext.TestRuns.Add(testRun); - testRuns.Add(testRun); - } - - await dbContext.SaveChangesAsync(); - - if (!testRuns.Any()) - return BadRequest("No tests found in uploaded assemblies."); - - return Ok(testRuns); - } - - /// - /// Returns all test runs with their results. - /// - /// A representing the asynchronous operation. - [HttpGet("history")] - public async Task GetHistory() - { - var runs = await dbContext.TestRuns - .Include(r => r.Results) - .OrderByDescending(r => r.StartedAt) - .ToListAsync(); - - return Ok(runs); - } -} diff --git a/HW6/MyNunitWeb.Api/TestResult.cs b/HW6/MyNunitWeb.Api/TestResult.cs deleted file mode 100644 index 4967a7b..0000000 --- a/HW6/MyNunitWeb.Api/TestResult.cs +++ /dev/null @@ -1,57 +0,0 @@ -// -// Copyright (c) khusainovilas. All rights reserved. -// - -namespace MyNunitWeb.Api; - -using MyNUnit; -using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; - -/// -/// Represents a single test execution result. -/// -public class TestResult -{ - /// - /// Gets or sets the identifier of the test result. - /// - [Key] - public int Id { get; set; } - - /// - /// Gets or sets the test name. - /// - [Required] - [MaxLength(128)] - public string TestName { get; set; } = string.Empty; - - /// - /// Gets or sets the execution status of the test. - /// - [Required] - public TestStatus Status { get; set; } - - /// - /// Gets or sets the test duration in milliseconds. - /// - [Required] - public double DurationMs { get; set; } - - /// - /// Gets or sets the message associated with the test result. - /// - [MaxLength(256)] - public string? Message { get; set; } - - /// - /// Gets or sets the related test run identifier. - /// - [ForeignKey("TestRun")] - public int TestRunId { get; set; } - - /// - /// Navigation property to the related test run. - /// - public TestRun? TestRun { get; set; } -} \ No newline at end of file diff --git a/HW6/MyNunitWeb.Api/TestRun.cs b/HW6/MyNunitWeb.Api/TestRun.cs deleted file mode 100644 index 7cd04ad..0000000 --- a/HW6/MyNunitWeb.Api/TestRun.cs +++ /dev/null @@ -1,48 +0,0 @@ -// -// Copyright (c) khusainovilas. All rights reserved. -// - -namespace MyNunitWeb.Api; - -using System.ComponentModel.DataAnnotations; - -/// -/// Represents a single execution of a test assembly. -/// -public class TestRun -{ - /// - /// Gets or sets the identifier of the test run. - /// - [Key] - public int Id { get; set; } - - /// - /// Gets or sets the start time of the test run. - /// - [Required] - public DateTime StartedAt { get; set; } - - /// - /// Gets or sets the number of passed tests. - /// - [Required] - public int Passed { get; set; } - - /// - /// Gets or sets the number of failed tests. - /// - [Required] - public int Failed { get; set; } - - /// - /// Gets or sets the number of ignored tests. - /// - [Required] - public int Ignored { get; set; } - - /// - /// Gets or sets the test results of this run. - /// - public List Results { get; set; } = []; -} \ No newline at end of file From 48fc0e781251744667e5a40b72e49ebd6ef4c80b Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Wed, 31 Dec 2025 22:52:22 +0300 Subject: [PATCH 09/10] fix ci --- HW6/MyNunitWeb.Api/AppDBContext.cs | 58 ------------------------------ 1 file changed, 58 deletions(-) delete mode 100644 HW6/MyNunitWeb.Api/AppDBContext.cs diff --git a/HW6/MyNunitWeb.Api/AppDBContext.cs b/HW6/MyNunitWeb.Api/AppDBContext.cs deleted file mode 100644 index ff2ace3..0000000 --- a/HW6/MyNunitWeb.Api/AppDBContext.cs +++ /dev/null @@ -1,58 +0,0 @@ -// -// Copyright (c) khusainovilas. All rights reserved. -// - -namespace MyNunitWeb.Api; - -using Microsoft.EntityFrameworkCore; - -/// -/// Entity Framework database context for MyNUnit test runs. -/// -public class AppDbContext : DbContext -{ - /// - /// Initializes a new instance of the class. - /// - /// Database context options. - public AppDbContext(DbContextOptions options) - : base(options) - { - } - - /// - /// Gets the collection of test runs. - /// - public DbSet TestRuns => this.Set(); - - /// - /// Gets the collection of test results. - /// - public DbSet TestResults => this.Set(); - - /// - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - base.OnModelCreating(modelBuilder); - - modelBuilder.Entity() - .HasMany(r => r.Results) - .WithOne() - .HasForeignKey(r => r.TestRunId) - .OnDelete(DeleteBehavior.Cascade); - - modelBuilder.Entity() - .HasIndex(r => r.StartedAt); - - modelBuilder.Entity() - .HasIndex(r => r.TestRunId); - - modelBuilder.Entity() - .Property(r => r.TestName) - .HasMaxLength(128); - - modelBuilder.Entity() - .Property(r => r.Message) - .HasMaxLength(256); - } -} \ No newline at end of file From 8d940900409afe476f4dc4a59e567a5fef730c9a Mon Sep 17 00:00:00 2001 From: khusainovilas Date: Wed, 31 Dec 2025 22:56:26 +0300 Subject: [PATCH 10/10] fix bug --- HW6/MyNunitWeb.Client/index.html | 2 +- HW6/MyNunitWeb.Client/script.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/HW6/MyNunitWeb.Client/index.html b/HW6/MyNunitWeb.Client/index.html index a3c453e..e202e29 100644 --- a/HW6/MyNunitWeb.Client/index.html +++ b/HW6/MyNunitWeb.Client/index.html @@ -13,7 +13,7 @@

        MyNUnitWeb

        Upload Assemblies and Run Tests

        - +
        diff --git a/HW6/MyNunitWeb.Client/script.js b/HW6/MyNunitWeb.Client/script.js index d62dfc6..659fb4e 100644 --- a/HW6/MyNunitWeb.Client/script.js +++ b/HW6/MyNunitWeb.Client/script.js @@ -36,7 +36,6 @@ document.addEventListener('DOMContentLoaded', () => { } } catch (err) { console.error('Network error:', err); - alert('Unable to connect to the server. Make sure dotnet run is running.'); } });