diff --git a/BPCalculator.BddTests/BMICalculator.feature b/BPCalculator.BddTests/BMICalculator.feature new file mode 100644 index 0000000..15aaa86 --- /dev/null +++ b/BPCalculator.BddTests/BMICalculator.feature @@ -0,0 +1,23 @@ +Feature: BMI Calculator + In order to know my BMI category + As a user + I want the system to calculate BMI correctly + + Scenario Outline: BMI category scenarios + Given a height of cm + And a weight of kg + When I calculate BMI + Then the BMI category should be "" + + Examples: + | heightCm | weightKg | expectedCategory | + | 170 | 50 | Underweight | + | 170 | 65 | Normal | + | 170 | 80 | Overweight | + | 170 | 100 | Obese | + + Scenario: BMI borderline between Normal and Overweight + Given a height of 170 cm + And a weight of 72 kg + When I calculate BMI + Then the BMI category should be "Normal" diff --git a/BPCalculator.BddTests/BMICalculator.feature.cs b/BPCalculator.BddTests/BMICalculator.feature.cs new file mode 100644 index 0000000..ab1ebf9 --- /dev/null +++ b/BPCalculator.BddTests/BMICalculator.feature.cs @@ -0,0 +1,177 @@ +// ------------------------------------------------------------------------------ +// +// This code was generated by SpecFlow (https://www.specflow.org/). +// SpecFlow Version:3.9.0.0 +// SpecFlow Generator Version:3.9.0.0 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +// ------------------------------------------------------------------------------ +#region Designer generated code +#pragma warning disable +namespace BPCalculator.BddTests +{ + using TechTalk.SpecFlow; + using System; + using System.Linq; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.9.0.0")] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public partial class BMICalculatorFeature : object, Xunit.IClassFixture, System.IDisposable + { + + private static TechTalk.SpecFlow.ITestRunner testRunner; + + private static string[] featureTags = ((string[])(null)); + + private Xunit.Abstractions.ITestOutputHelper _testOutputHelper; + +#line 1 "BMICalculator.feature" +#line hidden + + public BMICalculatorFeature(BMICalculatorFeature.FixtureData fixtureData, BPCalculator_BddTests_XUnitAssemblyFixture assemblyFixture, Xunit.Abstractions.ITestOutputHelper testOutputHelper) + { + this._testOutputHelper = testOutputHelper; + this.TestInitialize(); + } + + public static void FeatureSetup() + { + testRunner = TechTalk.SpecFlow.TestRunnerManager.GetTestRunner(); + TechTalk.SpecFlow.FeatureInfo featureInfo = new TechTalk.SpecFlow.FeatureInfo(new System.Globalization.CultureInfo("en-US"), "", "BMI Calculator", " In order to know my BMI category\r\n As a user\r\n I want the system to calculate" + + " BMI correctly", ProgrammingLanguage.CSharp, featureTags); + testRunner.OnFeatureStart(featureInfo); + } + + public static void FeatureTearDown() + { + testRunner.OnFeatureEnd(); + testRunner = null; + } + + public void TestInitialize() + { + } + + public void TestTearDown() + { + testRunner.OnScenarioEnd(); + } + + public void ScenarioInitialize(TechTalk.SpecFlow.ScenarioInfo scenarioInfo) + { + testRunner.OnScenarioInitialize(scenarioInfo); + testRunner.ScenarioContext.ScenarioContainer.RegisterInstanceAs(_testOutputHelper); + } + + public void ScenarioStart() + { + testRunner.OnScenarioStart(); + } + + public void ScenarioCleanup() + { + testRunner.CollectScenarioErrors(); + } + + void System.IDisposable.Dispose() + { + this.TestTearDown(); + } + + [Xunit.SkippableTheoryAttribute(DisplayName="BMI category scenarios")] + [Xunit.TraitAttribute("FeatureTitle", "BMI Calculator")] + [Xunit.TraitAttribute("Description", "BMI category scenarios")] + [Xunit.InlineDataAttribute("170", "50", "Underweight", new string[0])] + [Xunit.InlineDataAttribute("170", "65", "Normal", new string[0])] + [Xunit.InlineDataAttribute("170", "80", "Overweight", new string[0])] + [Xunit.InlineDataAttribute("170", "100", "Obese", new string[0])] + public void BMICategoryScenarios(string heightCm, string weightKg, string expectedCategory, string[] exampleTags) + { + string[] tagsOfScenario = exampleTags; + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + argumentsOfScenario.Add("heightCm", heightCm); + argumentsOfScenario.Add("weightKg", weightKg); + argumentsOfScenario.Add("expectedCategory", expectedCategory); + TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("BMI category scenarios", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 6 + this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((TagHelper.ContainsIgnoreTag(tagsOfScenario) || TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + this.ScenarioStart(); +#line 7 + testRunner.Given(string.Format("a height of {0} cm", heightCm), ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); +#line hidden +#line 8 + testRunner.And(string.Format("a weight of {0} kg", weightKg), ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); +#line hidden +#line 9 + testRunner.When("I calculate BMI", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); +#line hidden +#line 10 + testRunner.Then(string.Format("the BMI category should be \"{0}\"", expectedCategory), ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); +#line hidden + } + this.ScenarioCleanup(); + } + + [Xunit.SkippableFactAttribute(DisplayName="BMI borderline between Normal and Overweight")] + [Xunit.TraitAttribute("FeatureTitle", "BMI Calculator")] + [Xunit.TraitAttribute("Description", "BMI borderline between Normal and Overweight")] + public void BMIBorderlineBetweenNormalAndOverweight() + { + string[] tagsOfScenario = ((string[])(null)); + System.Collections.Specialized.OrderedDictionary argumentsOfScenario = new System.Collections.Specialized.OrderedDictionary(); + TechTalk.SpecFlow.ScenarioInfo scenarioInfo = new TechTalk.SpecFlow.ScenarioInfo("BMI borderline between Normal and Overweight", null, tagsOfScenario, argumentsOfScenario, featureTags); +#line 19 + this.ScenarioInitialize(scenarioInfo); +#line hidden + if ((TagHelper.ContainsIgnoreTag(tagsOfScenario) || TagHelper.ContainsIgnoreTag(featureTags))) + { + testRunner.SkipScenario(); + } + else + { + this.ScenarioStart(); +#line 20 + testRunner.Given("a height of 170 cm", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Given "); +#line hidden +#line 21 + testRunner.And("a weight of 72 kg", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "And "); +#line hidden +#line 22 + testRunner.When("I calculate BMI", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "When "); +#line hidden +#line 23 + testRunner.Then("the BMI category should be \"Normal\"", ((string)(null)), ((TechTalk.SpecFlow.Table)(null)), "Then "); +#line hidden + } + this.ScenarioCleanup(); + } + + [System.CodeDom.Compiler.GeneratedCodeAttribute("TechTalk.SpecFlow", "3.9.0.0")] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class FixtureData : System.IDisposable + { + + public FixtureData() + { + BMICalculatorFeature.FeatureSetup(); + } + + void System.IDisposable.Dispose() + { + BMICalculatorFeature.FeatureTearDown(); + } + } + } +} +#pragma warning restore +#endregion diff --git a/BPCalculator.BddTests/BMICalculatorSteps.cs b/BPCalculator.BddTests/BMICalculatorSteps.cs new file mode 100644 index 0000000..1b4b1d0 --- /dev/null +++ b/BPCalculator.BddTests/BMICalculatorSteps.cs @@ -0,0 +1,43 @@ +using BPCalculator; // to use BMICalculator +using FluentAssertions; +using TechTalk.SpecFlow; + +namespace BPCalculator.BddTests.StepDefinitions +{ + [Binding] + public class BMICalculatorSteps + { + private double _heightCm; + private double _weightKg; + private (double bmi, string category) _result; + + // Matches: "Given a height of cm" + [Given(@"a height of (.*) cm")] + public void GivenAHeightOfCm(double heightCm) + { + _heightCm = heightCm; + } + + // Matches: "And a weight of kg" + [Given(@"a weight of (.*) kg")] + public void GivenAWeightOfKg(double weightKg) + { + _weightKg = weightKg; + } + + // Matches: "When I calculate BMI" + [When(@"I calculate BMI")] + public void WhenICalculateBMI() + { + _result = BMICalculator.Calculate(_heightCm, _weightKg); + } + + // Matches: Then the BMI category should be "" + [Then(@"the BMI category should be ""(.*)""")] + public void ThenTheBMICategoryShouldBe(string expectedCategory) + { + _result.category.Should().Be(expectedCategory); + _result.bmi.Should().BeGreaterThan(0); + } + } +} diff --git a/BPCalculator.BddTests/BPCalculator.BddTests.csproj b/BPCalculator.BddTests/BPCalculator.BddTests.csproj new file mode 100644 index 0000000..9ae05dc --- /dev/null +++ b/BPCalculator.BddTests/BPCalculator.BddTests.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BPCalculator.BddTests/BloodPressureCategory.feature b/BPCalculator.BddTests/BloodPressureCategory.feature new file mode 100644 index 0000000..009416e --- /dev/null +++ b/BPCalculator.BddTests/BloodPressureCategory.feature @@ -0,0 +1,17 @@ +Feature: Blood pressure category + In order to understand my blood pressure + As a patient + I want to see the correct category for my readings + + Scenario Outline: Categorise blood pressure + Given a systolic value of + And a diastolic value of + When I calculate the blood pressure category + Then the result should be + + Examples: + | Systolic | Diastolic | Category | + | 90 | 60 | Ideal | + | 110 | 70 | Ideal | + | 135 | 85 | PreHigh | + | 150 | 95 | High | diff --git a/BPCalculator.BddTests/BloodPressureCategoryBddTests.cs b/BPCalculator.BddTests/BloodPressureCategoryBddTests.cs new file mode 100644 index 0000000..09c0351 --- /dev/null +++ b/BPCalculator.BddTests/BloodPressureCategoryBddTests.cs @@ -0,0 +1,30 @@ +using BPCalculator; +using Xunit; + +namespace BPCalculator.BddTests +{ + public class BloodPressureCategoryBddTests + { + [Theory(DisplayName = "BDD: Blood pressure category scenarios")] + [InlineData(90, 60, BPCategory.Ideal)] + [InlineData(110, 70, BPCategory.Ideal)] + [InlineData(135, 85, BPCategory.PreHigh)] + [InlineData(150, 95, BPCategory.High)] + public void BloodPressureCategory_Scenarios( + int systolic, int diastolic, BPCategory expectedCategory) + { + // GIVEN a blood pressure reading + var bp = new BloodPressure + { + Systolic = systolic, + Diastolic = diastolic + }; + + // WHEN I calculate the category + var actual = bp.Category; + + // THEN the result should match the expected category + Assert.Equal(expectedCategory, actual); + } + } +} diff --git a/BPCalculator.BddTests/BloodPressureCategorySteps.cs b/BPCalculator.BddTests/BloodPressureCategorySteps.cs new file mode 100644 index 0000000..4c4f675 --- /dev/null +++ b/BPCalculator.BddTests/BloodPressureCategorySteps.cs @@ -0,0 +1,49 @@ +using BPCalculator; +using FluentAssertions; +using TechTalk.SpecFlow; + +namespace BPCalculator.BddTests.StepDefinitions +{ + [Binding] + public class BloodPressureCategorySteps + { + private readonly ScenarioContext _scenarioContext; + + public BloodPressureCategorySteps(ScenarioContext scenarioContext) + { + _scenarioContext = scenarioContext; + } + + [Given(@"a systolic value of (.*)")] + public void GivenASystolicValueOf(int systolic) + { + var bp = new BloodPressure + { + Systolic = systolic + }; + + _scenarioContext["bp"] = bp; + } + + [Given(@"a diastolic value of (.*)")] + public void GivenADiastolicValueOf(int diastolic) + { + var bp = (BloodPressure)_scenarioContext["bp"]; + bp.Diastolic = diastolic; + } + + [When(@"I calculate the blood pressure category")] + public void WhenICalculateTheBloodPressureCategory() + { + var bp = (BloodPressure)_scenarioContext["bp"]; + _scenarioContext["category"] = bp.Category; + } + + [Then(@"the result should be (.*)")] + public void ThenTheResultShouldBe(string expectedCategory) + { + var actual = (BPCategory)_scenarioContext["category"]; + actual.ToString().Should().Be(expectedCategory); + } + } +} diff --git a/BPCalculator.PlaywrightTests/BPCalculator.PlaywrightTests.csproj b/BPCalculator.PlaywrightTests/BPCalculator.PlaywrightTests.csproj new file mode 100644 index 0000000..1c49240 --- /dev/null +++ b/BPCalculator.PlaywrightTests/BPCalculator.PlaywrightTests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/BPCalculator.PlaywrightTests/BmiPagePlaywrightTests.cs b/BPCalculator.PlaywrightTests/BmiPagePlaywrightTests.cs new file mode 100644 index 0000000..8836f3b --- /dev/null +++ b/BPCalculator.PlaywrightTests/BmiPagePlaywrightTests.cs @@ -0,0 +1,68 @@ +using System.Threading.Tasks; +using Microsoft.Playwright; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +// Aliases to avoid any Assert name clashes +using MsAssert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert; +using MsStringAssert = Microsoft.VisualStudio.TestTools.UnitTesting.StringAssert; + +namespace BPCalculator.PlaywrightTests +{ + [TestClass] + public class BmiPagePlaywrightTests + { + private IPlaywright _playwright = null!; + private IBrowser _browser = null!; + private IPage _page = null!; + + [TestInitialize] + public async Task SetUp() + { + _playwright = await Playwright.CreateAsync(); + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true + }); + _page = await _browser.NewPageAsync(); + } + + [TestCleanup] + public async Task TearDown() + { + await _browser.CloseAsync(); + _playwright.Dispose(); + } + + [TestMethod] + public async Task BmiResult_IsCalculatedAndVisible() + { + // Arrange + await _page.GotoAsync("http://localhost:5000/"); + + // Fill BP (values don’t matter for BMI) + await _page.FillAsync("input[name='BP.Systolic']", "120"); + await _page.FillAsync("input[name='BP.Diastolic']", "80"); + + // Act – fill BMI fields + await _page.FillAsync("input[name='HeightCm']", "170"); + await _page.FillAsync("input[name='WeightKg']", "65"); + + // Click submit + await _page.ClickAsync("input[type='submit']"); + + // Assert – BMI result box should be visible + var bmiResult = _page.Locator("#bmiResult"); + await Assertions.Expect(bmiResult).ToBeVisibleAsync(); + + var text = await bmiResult.InnerTextAsync(); + + // text should not be empty + MsAssert.IsFalse(string.IsNullOrWhiteSpace(text), $"BMI result text was: '{text}'"); + + // and should include a category in parentheses, e.g. "(Normal)" + MsStringAssert.Contains(text, "(", $"BMI result text was: '{text}'"); + + + } + } +} diff --git a/BPCalculator.PlaywrightTests/UnitTest1.cs b/BPCalculator.PlaywrightTests/UnitTest1.cs new file mode 100644 index 0000000..0cdcf88 --- /dev/null +++ b/BPCalculator.PlaywrightTests/UnitTest1.cs @@ -0,0 +1,11 @@ +namespace BPCalculator.PlaywrightTests +{ + public class UnitTest1 + { + [Fact] + public void Test1() + { + + } + } +} \ No newline at end of file diff --git a/BPCalculator.SeleniumTests/BPCalculator.SeleniumTests.vbproj b/BPCalculator.SeleniumTests/BPCalculator.SeleniumTests.vbproj new file mode 100644 index 0000000..7e3c1df --- /dev/null +++ b/BPCalculator.SeleniumTests/BPCalculator.SeleniumTests.vbproj @@ -0,0 +1,27 @@ + + + + BPCalculator.SeleniumTests + net8.0 + + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/BPCalculator.SeleniumTests/UnitTest1.vb b/BPCalculator.SeleniumTests/UnitTest1.vb new file mode 100644 index 0000000..33f12e3 --- /dev/null +++ b/BPCalculator.SeleniumTests/UnitTest1.vb @@ -0,0 +1,63 @@ +Imports System +Imports Microsoft.VisualStudio.TestTools.UnitTesting +Imports OpenQA.Selenium +Imports OpenQA.Selenium.Chrome + +' Alias to avoid Assert ambiguity if xUnit is referenced anywhere +Imports MsAssert = Microsoft.VisualStudio.TestTools.UnitTesting.Assert + +Namespace BPCalculator.SeleniumTests + + + Public Class BloodPressureSeleniumTests + + Private _driver As IWebDriver + + + Public Sub SetUp() + Dim options As New ChromeOptions() + options.AddArgument("--headless=new") ' run headless for CI + options.AddArgument("--no-sandbox") + options.AddArgument("--disable-dev-shm-usage") + + _driver = New ChromeDriver(options) + End Sub + + + Public Sub TearDown() + If _driver IsNot Nothing Then + _driver.Quit() + End If + End Sub + + + Public Sub BpCalculator_Displays_Ideal_For_100_60() + ' NOTE: Make sure the BPCalculator web app is running at http://localhost:5000 + _driver.Navigate().GoToUrl("http://localhost:5000/") + + ' Fill in BP values + Dim systolic = _driver.FindElement(By.Name("BP.Systolic")) + systolic.Clear() + systolic.SendKeys("100") + + Dim diastolic = _driver.FindElement(By.Name("BP.Diastolic")) + diastolic.Clear() + diastolic.SendKeys("60") + + ' Submit the form + Dim submit = _driver.FindElement(By.CssSelector("input[type='submit']")) + submit.Click() + + ' Simple wait for the result to render + _driver.Manage().Timeouts().ImplicitWait = TimeSpan.FromSeconds(2) + + ' Read the BP result + Dim bpResultDiv = _driver.FindElement(By.Id("bpResult")) + Dim text = bpResultDiv.Text + + MsAssert.AreEqual("Ideal", text, $"Expected BP category 'Ideal' but got '{text}'") + End Sub + + End Class + +End Namespace diff --git a/BPCalculator.UnitTests/BMICalculatorTests.cs b/BPCalculator.UnitTests/BMICalculatorTests.cs new file mode 100644 index 0000000..4af5167 --- /dev/null +++ b/BPCalculator.UnitTests/BMICalculatorTests.cs @@ -0,0 +1,51 @@ +using Xunit; + +namespace BPCalculator.UnitTests +{ + public class BMICalculatorTests + { + // Height is constant so the weight decides category + [Theory(DisplayName = "BMI: category scenarios")] + [InlineData(170, 50, "Underweight")] + [InlineData(170, 65, "Normal")] + [InlineData(170, 80, "Overweight")] + [InlineData(170, 100, "Obese")] + public void Calculate_ReturnsExpectedCategory( + double heightCm, + double weightKg, + string expectedCategory) + { + // Act + var (bmi, category) = BMICalculator.Calculate(heightCm, weightKg); + + // Assert + Assert.True(bmi > 0); + Assert.Equal(expectedCategory, category); + } + + [Fact] + public void Calculate_RoundsToOneDecimalPlace() + { + // Act + var (bmi, _) = BMICalculator.Calculate(180, 80); // ~24.69 + + // Assert – should be rounded to 1 decimal (24.7) + Assert.Equal(24.7, bmi); + } + + [Fact] + public void Calculate_HeavierWeightProducesHigherBMI() + { + // Arrange + double height = 170; + + // Act + var (bmiLighter, _) = BMICalculator.Calculate(height, 65); + var (bmiHeavier, _) = BMICalculator.Calculate(height, 80); + + // Assert + Assert.True(bmiHeavier > bmiLighter); + } + + } +} diff --git a/BPCalculator.UnitTests/BPCalculator.UnitTests.csproj b/BPCalculator.UnitTests/BPCalculator.UnitTests.csproj new file mode 100644 index 0000000..e6a66fd --- /dev/null +++ b/BPCalculator.UnitTests/BPCalculator.UnitTests.csproj @@ -0,0 +1,29 @@ + + + + net9.0 + enable + enable + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + diff --git a/BPCalculator.UnitTests/BloodPressureCategoryTests.cs b/BPCalculator.UnitTests/BloodPressureCategoryTests.cs new file mode 100644 index 0000000..43f14c1 --- /dev/null +++ b/BPCalculator.UnitTests/BloodPressureCategoryTests.cs @@ -0,0 +1,30 @@ +using BPCalculator; +using Xunit; + +namespace BPCalculator.UnitTests +{ + public class BloodPressureCategoryTests + { + [Theory] + [InlineData(90, 60, BPCategory.Ideal)] + [InlineData(110, 70, BPCategory.Ideal)] + [InlineData(135, 85, BPCategory.PreHigh)] + [InlineData(150, 95, BPCategory.High)] + public void Category_Is_Calculated_Correctly( + int systolic, int diastolic, BPCategory expectedCategory) + { + // Arrange + var bp = new BloodPressure + { + Systolic = systolic, + Diastolic = diastolic + }; + + // Act + var actual = bp.Category; + + // Assert + Assert.Equal(expectedCategory, actual); + } + } +} diff --git a/BPCalculator.sln b/BPCalculator.sln index 652d73a..cd9623d 100644 --- a/BPCalculator.sln +++ b/BPCalculator.sln @@ -1,24 +1,103 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30002.166 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36518.9 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BPCalculator", "BPCalculator\BPCalculator.csproj", "{5966DA6C-819D-4303-ADC7-425B8541EC84}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BPCalculator.UnitTests", "BPCalculator.UnitTests\BPCalculator.UnitTests.csproj", "{A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BPCalculator.BddTests", "BPCalculator.BddTests\BPCalculator.BddTests.csproj", "{59819ED5-D778-4737-8D70-9A61494224ED}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{478B5C70-9FEE-464F-B914-E80DA228D2D7}" + ProjectSection(SolutionItems) = preProject + ci.yml = ci.yml + sonar-ci.yml = sonar-ci.yml + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BPCalculator.PlaywrightTests", "BPCalculator.PlaywrightTests\BPCalculator.PlaywrightTests.csproj", "{D03C58EC-21EC-420A-9FEE-394C32D896AA}" +EndProject +Project("{F184B08F-C81C-45F6-A57F-5ABD9991F28F}") = "BPCalculator.SeleniumTests", "BPCalculator.SeleniumTests\BPCalculator.SeleniumTests.vbproj", "{E305B2F5-9758-4AF8-927E-0CA366CDA494}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {5966DA6C-819D-4303-ADC7-425B8541EC84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5966DA6C-819D-4303-ADC7-425B8541EC84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5966DA6C-819D-4303-ADC7-425B8541EC84}.Debug|x64.ActiveCfg = Debug|Any CPU + {5966DA6C-819D-4303-ADC7-425B8541EC84}.Debug|x64.Build.0 = Debug|Any CPU + {5966DA6C-819D-4303-ADC7-425B8541EC84}.Debug|x86.ActiveCfg = Debug|Any CPU + {5966DA6C-819D-4303-ADC7-425B8541EC84}.Debug|x86.Build.0 = Debug|Any CPU {5966DA6C-819D-4303-ADC7-425B8541EC84}.Release|Any CPU.ActiveCfg = Release|Any CPU {5966DA6C-819D-4303-ADC7-425B8541EC84}.Release|Any CPU.Build.0 = Release|Any CPU + {5966DA6C-819D-4303-ADC7-425B8541EC84}.Release|x64.ActiveCfg = Release|Any CPU + {5966DA6C-819D-4303-ADC7-425B8541EC84}.Release|x64.Build.0 = Release|Any CPU + {5966DA6C-819D-4303-ADC7-425B8541EC84}.Release|x86.ActiveCfg = Release|Any CPU + {5966DA6C-819D-4303-ADC7-425B8541EC84}.Release|x86.Build.0 = Release|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Debug|x64.ActiveCfg = Debug|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Debug|x64.Build.0 = Debug|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Debug|x86.ActiveCfg = Debug|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Debug|x86.Build.0 = Debug|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Release|Any CPU.Build.0 = Release|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Release|x64.ActiveCfg = Release|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Release|x64.Build.0 = Release|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Release|x86.ActiveCfg = Release|Any CPU + {A94AE3F9-BB5C-4B0D-ABE3-8C6446368DC4}.Release|x86.Build.0 = Release|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Debug|x64.ActiveCfg = Debug|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Debug|x64.Build.0 = Debug|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Debug|x86.ActiveCfg = Debug|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Debug|x86.Build.0 = Debug|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Release|Any CPU.Build.0 = Release|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Release|x64.ActiveCfg = Release|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Release|x64.Build.0 = Release|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Release|x86.ActiveCfg = Release|Any CPU + {59819ED5-D778-4737-8D70-9A61494224ED}.Release|x86.Build.0 = Release|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Debug|x64.ActiveCfg = Debug|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Debug|x64.Build.0 = Debug|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Debug|x86.ActiveCfg = Debug|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Debug|x86.Build.0 = Debug|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Release|Any CPU.Build.0 = Release|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Release|x64.ActiveCfg = Release|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Release|x64.Build.0 = Release|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Release|x86.ActiveCfg = Release|Any CPU + {D03C58EC-21EC-420A-9FEE-394C32D896AA}.Release|x86.Build.0 = Release|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Debug|x64.ActiveCfg = Debug|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Debug|x64.Build.0 = Debug|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Debug|x86.ActiveCfg = Debug|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Debug|x86.Build.0 = Debug|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Release|Any CPU.Build.0 = Release|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Release|x64.ActiveCfg = Release|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Release|x64.Build.0 = Release|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Release|x86.ActiveCfg = Release|Any CPU + {E305B2F5-9758-4AF8-927E-0CA366CDA494}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {478B5C70-9FEE-464F-B914-E80DA228D2D7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0888B091-BD2A-49F1-9C68-09927D2EC2D8} EndGlobalSection diff --git a/BPCalculator/BMICalculator.cs b/BPCalculator/BMICalculator.cs new file mode 100644 index 0000000..7e0fc55 --- /dev/null +++ b/BPCalculator/BMICalculator.cs @@ -0,0 +1,21 @@ +namespace BPCalculator +{ + public static class BMICalculator + { + // New feature: BMI calculation + category + public static (double bmi, string category) Calculate(double heightCm, double weightKg) + { + var heightM = heightCm / 100.0; + var bmi = System.Math.Round(weightKg / (heightM * heightM), 1); + + string category = + bmi < 18.5 ? "Underweight" : + bmi < 25.0 ? "Normal" : + bmi < 30.0 ? "Overweight" : + "Obese"; + + return (bmi, category); + } + } +} + diff --git a/BPCalculator/BloodPressure.cs b/BPCalculator/BloodPressure.cs index 3e8fee9..ce9f8fa 100644 --- a/BPCalculator/BloodPressure.cs +++ b/BPCalculator/BloodPressure.cs @@ -31,9 +31,27 @@ public BPCategory Category { get { - // implement as part of project - //throw new NotImplementedException("not implemented yet"); - return new BPCategory(); // replace this + // High blood pressure + if (Systolic >= 140 || Diastolic >= 90) + { + return BPCategory.High; + } + + // Pre-high blood pressure + if ((Systolic >= 120 && Systolic <= 139) || + (Diastolic >= 80 && Diastolic <= 89)) + { + return BPCategory.PreHigh; + } + + // ideal blood pressure + if (Systolic >= 90 && Systolic <= 119 && + Diastolic >= 60 && Diastolic <= 79) + { + return BPCategory.Ideal; + } + // otherwise, Low blood pressure + return BPCategory.Low; } } } diff --git a/BPCalculator/Pages/Index.cshtml b/BPCalculator/Pages/Index.cshtml index 26fd873..ca859bf 100644 --- a/BPCalculator/Pages/Index.cshtml +++ b/BPCalculator/Pages/Index.cshtml @@ -3,38 +3,117 @@ @{ ViewData["Title"] = "BP Category Calculator"; -} + string resultClass = ""; + // default BMI result style (neutral) + string bmiResultClass = "alert alert-secondary"; + + // BP result colour + if (ViewData.ModelState.IsValid && Model.BP != null) + { + switch (Model.BP.Category) + { + case BPCategory.Low: + resultClass = "alert alert-primary"; // blue + break; + case BPCategory.Ideal: + resultClass = "alert alert-success"; // green + break; + case BPCategory.PreHigh: + resultClass = "alert alert-warning"; // orange + break; + case BPCategory.High: + resultClass = "alert alert-danger"; // red + break; + default: + resultClass = "alert alert-secondary"; + break; + } + } + + // BMI result colour + if (Model.BMI.HasValue) + { + bmiResultClass = Model.BMICategory == "Normal" + ? "alert alert-success" + : "alert alert-warning"; + } +}

BP Category Calculator


+
-
-
-
- - - -
-
- - - -
-
- -
- @if (ViewData.ModelState.IsValid) - { -
- @Html.DisplayFor(model => model.BP.Category, new { htmlAttributes = new { @class = "form-control" } }) +
+
+ + +
+ +
+ + + +
+ +
+ + + +
+ + +
BMI Calculator
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+ + + @* BP result *@ + @if (ViewData.ModelState.IsValid && Model.BP != null) + { +
+ +
+ @Model.BP.Category +
+
+ } + + @* BMI result (always rendered) *@ +
+ +
+ @if (Model.BMI.HasValue) + { + @($"{Model.BMI} ({Model.BMICategory})") + } + else + { + No BMI calculated yet. + } +
- } - + +
+
@section Scripts { - @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} + } diff --git a/BPCalculator/Pages/Index.cshtml.cs b/BPCalculator/Pages/Index.cshtml.cs index f96dc16..89885d6 100644 --- a/BPCalculator/Pages/Index.cshtml.cs +++ b/BPCalculator/Pages/Index.cshtml.cs @@ -7,9 +7,20 @@ namespace BPCalculator.Pages { public class BloodPressureModel : PageModel { - [BindProperty] // bound on POST + [BindProperty] // bound on POST public BloodPressure BP { get; set; } + // ---- BMI NEW FEATURE PROPERTIES ---- + [BindProperty] + public double? HeightCm { get; set; } + + [BindProperty] + public double? WeightKg { get; set; } + + public double? BMI { get; private set; } + + public string BMICategory { get; private set; } + // setup initial data public void OnGet() { @@ -19,12 +30,30 @@ public void OnGet() // POST, validate public IActionResult OnPost() { - // extra validation + // extra validation for blood pressure if (!(BP.Systolic > BP.Diastolic)) { ModelState.AddModelError("", "Systolic must be greater than Diastolic"); } + + // ---- BMI new feature logic ---- + if (HeightCm.HasValue && HeightCm > 0 && + WeightKg.HasValue && WeightKg > 0) + { + var (bmi, category) = BMICalculator.Calculate( + HeightCm.Value, + WeightKg.Value); + + BMI = bmi; + BMICategory = category; + } + else + { + BMI = null; + BMICategory = null; + } + return Page(); } } -} \ No newline at end of file +} diff --git a/ci.yml b/ci.yml new file mode 100644 index 0000000..318227f --- /dev/null +++ b/ci.yml @@ -0,0 +1,69 @@ +name: .NET CI with SonarCloud + +on: + push: + branches: + - main + - feature/** + pull_request: + branches: + - main + +jobs: + build-test-sonar: + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_ORG: ${{ secrets.SONAR_ORG }} + SONAR_PROJECT_KEY: ${{ secrets.SONAR_PROJECT_KEY }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # SonarCloud prefers full history + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Install SonarScanner for .NET + run: dotnet tool install --global dotnet-sonarscanner + + - name: Add .NET tools to PATH + run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH + + # ---- SonarCloud begin ---- + - name: SonarCloud - Begin analysis + run: > + dotnet-sonarscanner begin + /k:"$SONAR_PROJECT_KEY" + /o:"$SONAR_ORG" + /d:sonar.host.url="https://sonarcloud.io" + /d:sonar.token="$SONAR_TOKEN" + /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" + # -------------------------- + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test with coverage + run: > + dotnet test --no-build --configuration Release + /p:CollectCoverage=true + /p:CoverletOutput=./TestResults/ + /p:CoverletOutputFormat=opencover + + # ---- SonarCloud end ---- + - name: SonarCloud - End analysis + run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN" + # ------------------------ diff --git a/k6/bp_performance_test.js b/k6/bp_performance_test.js new file mode 100644 index 0000000..bc0e455 --- /dev/null +++ b/k6/bp_performance_test.js @@ -0,0 +1,24 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '10s', target: 10 }, // ramp to 10 VUs + { duration: '10s', target: 20 }, // ramp to 20 VUs + { duration: '10s', target: 0 }, // ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% under 500ms + http_req_failed: ['rate<0.01'], // <1% errors + }, +}; + +export default function () { + const res = http.get('http://localhost:5000/'); + + check(res, { + 'status is 200': (r) => r.status === 200, + }); + + sleep(1); +} diff --git a/sonar-ci.yml b/sonar-ci.yml new file mode 100644 index 0000000..f22411b --- /dev/null +++ b/sonar-ci.yml @@ -0,0 +1,70 @@ +name: .NET CI with SonarCloud + +on: + push: + branches: + - main + - feature/** + pull_request: + branches: + - main + +jobs: + build-test-sonar: + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_ORG: ${{ secrets.SONAR_ORG }} + SONAR_PROJECT_KEY: ${{ secrets.SONAR_PROJECT_KEY }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # SonarCloud prefers full history + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + + - name: Install SonarScanner for .NET + run: dotnet tool install --global dotnet-sonarscanner + + - name: Add .NET tools to PATH + run: echo "$HOME/.dotnet/tools" >> $GITHUB_PATH + + # ---- SonarCloud begin ---- + - name: SonarCloud - Begin analysis + run: > + dotnet-sonarscanner begin + /k:"$SONAR_PROJECT_KEY" + /o:"$SONAR_ORG" + /d:sonar.host.url="https://sonarcloud.io" + /d:sonar.token="$SONAR_TOKEN" + /d:sonar.cs.opencover.reportsPaths="**/coverage.opencover.xml" + # -------------------------- + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test UnitTests with coverage + run: > + dotnet test ./BPCalculator.UnitTests/BPCalculator.UnitTests.csproj + --no-build --configuration Release + /p:CollectCoverage=true + /p:CoverletOutput=./TestResults/ + /p:CoverletOutputFormat=opencover + + # ---- SonarCloud end ---- + - name: SonarCloud - End analysis + run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN" + # ------------------------