diff --git a/HW6/HW6.sln b/HW6/HW6.sln new file mode 100644 index 0000000..eda89b3 --- /dev/null +++ b/HW6/HW6.sln @@ -0,0 +1,26 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +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 + 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 + {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.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/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/MyNUnit.csproj b/HW6/MyNUnit/MyNUnit.csproj new file mode 100644 index 0000000..7dc5827 --- /dev/null +++ b/HW6/MyNUnit/MyNUnit.csproj @@ -0,0 +1,22 @@ + + + + Library + net9.0 + enable + enable + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/HW6/MyNUnit/TestDiscovery.cs b/HW6/MyNUnit/TestDiscovery.cs new file mode 100644 index 0000000..6925abb --- /dev/null +++ b/HW6/MyNUnit/TestDiscovery.cs @@ -0,0 +1,53 @@ +// +// 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 +{ + /// + /// Discovers test classes in a DLL. + /// + /// Path to test assembly. + /// List of test class types. + public static IReadOnlyList DiscoverFromDll(string dllPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dllPath); + + if (!File.Exists(dllPath)) + { + throw new FileNotFoundException("DLL not found", dllPath); + } + + if (!dllPath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("File must be a DLL", nameof(dllPath)); + } + + 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) + .Any(m => m.GetCustomAttribute() != 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..06bd893 --- /dev/null +++ b/HW6/MyNUnit/TestResult.cs @@ -0,0 +1,54 @@ +// +// 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; } +} + +/// +/// 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..c9e4cf5 --- /dev/null +++ b/HW6/MyNUnit/TestRunner.cs @@ -0,0 +1,218 @@ +// +// 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: {ex.Message}", + }); + } + } + 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); + } + catch (Exception ex) + { + stopwatch.Stop(); + InvokeAfterSafely(after, instance); + + return Fail(testName, ex.Message, stopwatch.Elapsed); + } + } + + 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) + { + return new TestResult + { + TestName = name, + Status = TestStatus.Failed, + Duration = time, + Message = message, + }; + } +} 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/.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/20251231185953_InitialCreate.Designer.cs b/HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.Designer.cs new file mode 100644 index 0000000..095c6ee --- /dev/null +++ b/HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.Designer.cs @@ -0,0 +1,88 @@ +// +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(MyNUnitDbContext))] + [Migration("20251231185953_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.Models.Run", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssemblyNames") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Runs"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.Models.TestRunResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TestName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.ToTable("TestResults"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.Models.TestRunResult", b => + { + b.HasOne("MyNunitWeb.Api.Models.Run", "Run") + .WithMany("Results") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.Models.Run", b => + { + b.Navigation("Results"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.cs b/HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.cs new file mode 100644 index 0000000..3f4ebec --- /dev/null +++ b/HW6/MyNunitWeb.Api/Migrations/20251231185953_InitialCreate.cs @@ -0,0 +1,67 @@ +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: "Runs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Timestamp = table.Column(type: "TEXT", nullable: false), + AssemblyNames = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Runs", 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", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + 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_Runs_RunId", + column: x => x.RunId, + principalTable: "Runs", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_TestResults_RunId", + table: "TestResults", + column: "RunId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "TestResults"); + + migrationBuilder.DropTable( + name: "Runs"); + } + } +} diff --git a/HW6/MyNunitWeb.Api/Migrations/MyNUnitDbContextModelSnapshot.cs b/HW6/MyNunitWeb.Api/Migrations/MyNUnitDbContextModelSnapshot.cs new file mode 100644 index 0000000..1492075 --- /dev/null +++ b/HW6/MyNunitWeb.Api/Migrations/MyNUnitDbContextModelSnapshot.cs @@ -0,0 +1,85 @@ +// +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(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.Models.Run", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AssemblyNames") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Runs"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.Models.TestRunResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("TestName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId"); + + b.ToTable("TestResults"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.Models.TestRunResult", b => + { + b.HasOne("MyNunitWeb.Api.Models.Run", "Run") + .WithMany("Results") + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Run"); + }); + + modelBuilder.Entity("MyNunitWeb.Api.Models.Run", b => + { + b.Navigation("Results"); + }); +#pragma warning restore 612, 618 + } + } +} 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/MyNunitWeb.Api.csproj b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj new file mode 100644 index 0000000..930f0ac --- /dev/null +++ b/HW6/MyNunitWeb.Api/MyNunitWeb.Api.csproj @@ -0,0 +1,41 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + 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..5e5a10f --- /dev/null +++ b/HW6/MyNunitWeb.Api/Program.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +using Microsoft.EntityFrameworkCore; +using MyNunitWeb.Api; + +var builder = WebApplication.CreateBuilder(args); + +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(); \ 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.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 diff --git a/HW6/MyNunitWeb.Client/index.html b/HW6/MyNunitWeb.Client/index.html new file mode 100644 index 0000000..e202e29 --- /dev/null +++ b/HW6/MyNunitWeb.Client/index.html @@ -0,0 +1,61 @@ + + + + + + MyNUnit Web + + + +
+

MyNUnitWeb

+ +
+

Upload Assemblies and Run Tests

+
+ + +
+
+ + + +
+

Run History

+
    +
    + + +
    + + + + diff --git a/HW6/MyNunitWeb.Client/script.js b/HW6/MyNunitWeb.Client/script.js new file mode 100644 index 0000000..659fb4e --- /dev/null +++ b/HW6/MyNunitWeb.Client/script.js @@ -0,0 +1,106 @@ +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); + } + }); + + 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 new file mode 100644 index 0000000..a8359c3 --- /dev/null +++ b/HW6/MyNunitWeb.Client/style.css @@ -0,0 +1,109 @@ +body { + font-family: 'Segoe UI', Arial, sans-serif; + background: #f0f2f5; + color: #333; + margin: 0; + padding: 20px; +} + +.app { + max-width: 1200px; + margin: 0 auto; +} + +.app__title { + text-align: center; + color: #1a1a1a; + margin-bottom: 40px; +} + +.card { + padding: 30px; + margin-bottom: 40px; + border-radius: 12px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); +} + +.card_history .history_list { + max-height: 400px; + overflow-y: auto; + padding-right: 10px; +} + +.card__title { + margin-top: 0; + color: #2c3e50; + border-bottom: 2px solid #0066cc; + padding-bottom: 10px; +} + +.upload_form__input { + display: block; + margin: 20px 0; + padding: 10px; + font-size: 16px; +} + +.upload_form__button { + padding: 14px 32px; + background: #0066cc; + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + transition: background 0.3s; +} + +.upload_form__button:hover { + background: #0050a0; +} + +.results_table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; +} + +.results_table th, +.results_table td { + padding: 14px; + text-align: left; + border-bottom: 1px solid #eee; +} + +.results_table th { + background: #f8f9fa; + font-weight: 600; +} + +.summary { + font-size: 18px; + font-weight: bold; + color: #2c3e50; + margin-bottom: 20px; +} + +.history_list { + list-style: none; + padding: 0; +} + +.history_list li { + padding: 20px; + background: #f8f9fa; + margin: 12px 0; + border-radius: 10px; + border-left: 6px solid #0066cc; + cursor: pointer; + transition: all 0.2s; +} + +.history_list li:hover { + background: #e8f4ff; + transform: translateX(5px); +} + +.passed { color: green; font-weight: bold; } +.failed { color: red; font-weight: bold; } +.ignored { color: orange; font-weight: bold; } \ No newline at end of file