diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aac0deb..77a9ba3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,30 +2,7 @@ on: [push, pull_request] jobs: - build-ubuntu: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '9.x' - - - name: Build - run: | - for f in $(find . -name "*.sln"); do - if [[ "$f" != *"CalculatorOnForms2"* ]]; then - dotnet build "$f" - fi - done - - - name: Run tests - run: | - for f in $(find . -name "*.sln"); do - if [[ "$f" != *"CalculatorOnForms2"* ]]; then - dotnet test "$f" - fi - done - + build-windows: runs-on: windows-latest steps: diff --git a/HW3/CalculatorCore.Test/CalculatorCore.Test.csproj b/HW3/CalculatorCore.Test/CalculatorCore.Test.csproj new file mode 100644 index 0000000..96e3dbb --- /dev/null +++ b/HW3/CalculatorCore.Test/CalculatorCore.Test.csproj @@ -0,0 +1,39 @@ + + + + net8.0-windows + enable + enable + true + false + true + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/HW3/CalculatorCore.Test/CalculatorTest.cs b/HW3/CalculatorCore.Test/CalculatorTest.cs new file mode 100644 index 0000000..5673e71 --- /dev/null +++ b/HW3/CalculatorCore.Test/CalculatorTest.cs @@ -0,0 +1,203 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace CalculatorCore.Test; + +using NUnit.Framework; + +/// +/// A set of unit tests to verify the business logic of the class. +/// These tests check arithmetic operations, error handling, and helper methods. +/// +public class CalculatorTest +{ + private CalculatorLogic calculator; + + /// + /// Runs before each test to create a fresh calculator instance. + /// + [SetUp] + public void Setup() + { + this.calculator = new CalculatorLogic(); + } + + /// + /// Tests that digits are appended correctly to the display. + /// + [Test] + public void CalculatorLogic_AppendDigit_AppendsDigitsCorrectly() + { + this.calculator.AppendDigit("1"); + this.calculator.AppendDigit("2"); + + Assert.That(this.calculator.Display, Is.EqualTo("12")); + } + + /// + /// Tests that invalid characters (non-numeric and not a dot) are ignored. + /// + [Test] + public void CalculatorLogic_AppendDigit_InvalidCharacter_IsIgnored() + { + this.calculator.AppendDigit("a"); + + Assert.That(this.calculator.Display, Is.EqualTo("0")); + } + + /// + /// Tests that only one decimal dot can be added. + /// + [Test] + public void CalculatorLogic_AppendDigit_DoubleDot_IsIgnored() + { + this.calculator.AppendDigit("1"); + this.calculator.AppendDigit(","); + this.calculator.AppendDigit(","); + this.calculator.AppendDigit("5"); + + Assert.That(this.calculator.Display, Is.EqualTo("1,5")); + } + + /// + /// Tests that addition returns the correct result. + /// + [Test] + public void CalculatorLogic_SetOperator_Addition_ReturnsCorrectResult() + { + this.calculator.AppendDigit("2"); + this.calculator.SetOperator("+"); + this.calculator.AppendDigit("3"); + this.calculator.Calculate(); + + Assert.That(this.calculator.Display, Is.EqualTo("5")); + } + + /// + /// Tests that subtraction returns the correct result. + /// + [Test] + public void CalculatorLogic_SetOperator_Subtraction_ReturnsCorrectResult() + { + this.calculator.AppendDigit("9"); + this.calculator.SetOperator("-"); + this.calculator.AppendDigit("4"); + this.calculator.Calculate(); + + Assert.That(this.calculator.Display, Is.EqualTo("5")); + } + + /// + /// Tests that multiplication returns the correct result. + /// + [Test] + public void CalculatorLogic_SetOperator_Multiplication_ReturnsCorrectResult() + { + this.calculator.AppendDigit("6"); + this.calculator.SetOperator("*"); + this.calculator.AppendDigit("7"); + this.calculator.Calculate(); + + Assert.That(this.calculator.Display, Is.EqualTo("42")); + } + + /// + /// Tests that division returns the correct result. + /// + [Test] + public void CalculatorLogic_SetOperator_Division_ReturnsCorrectResult() + { + this.calculator.AppendDigit("8"); + this.calculator.SetOperator("/"); + this.calculator.AppendDigit("2"); + this.calculator.Calculate(); + + Assert.That(this.calculator.Display, Is.EqualTo("4")); + } + + /// + /// Tests that division by zero results in an error message. + /// + [Test] + public void CalculatorLogic_SetOperator_DivisionByZero_ShowsError() + { + this.calculator.AppendDigit("8"); + this.calculator.SetOperator("/"); + this.calculator.AppendDigit("0"); + this.calculator.Calculate(); + + Assert.That(this.calculator.Display, Is.EqualTo("Error")); + } + + /// + /// Tests that the Clear method resets the calculator state completely. + /// + [Test] + public void CalculatorLogic_Clear_ResetsAllState() + { + this.calculator.AppendDigit("9"); + this.calculator.SetOperator("+"); + this.calculator.AppendDigit("1"); + this.calculator.Clear(); + + Assert.That(this.calculator.Display, Is.EqualTo("0")); + } + + /// + /// Tests that ClearEnter only clears the current input, not the stored result. + /// + [Test] + public void CalculatorLogic_ClearEnter_ClearsOnlyCurrentInput() + { + this.calculator.AppendDigit("9"); + this.calculator.SetOperator("+"); + this.calculator.AppendDigit("3"); + this.calculator.ClearEnter(); + + Assert.That(this.calculator.Display, Is.EqualTo("9")); + } + + /// + /// Tests that Backspace removes the last entered digit. + /// + [Test] + public void CalculatorLogic_Backspace_RemovesLastDigit() + { + this.calculator.AppendDigit("1"); + this.calculator.AppendDigit("2"); + this.calculator.AppendDigit("3"); + this.calculator.Backspace(); + + Assert.That(this.calculator.Display, Is.EqualTo("12")); + } + + /// + /// Tests that Backspace on empty input keeps the display as "0". + /// + [Test] + public void CalculatorLogic_Backspace_OnEmptyInput_KeepsZero() + { + this.calculator.Backspace(); + + Assert.That(this.calculator.Display, Is.EqualTo("0")); + } + + /// + /// Tests that sequential operations use the previous result as the first operand. + /// + [Test] + public void CalculatorLogic_Calculate_SequentialOperations_UsesPreviousResult() + { + this.calculator.AppendDigit("5"); + this.calculator.SetOperator("+"); + this.calculator.AppendDigit("5"); + this.calculator.Calculate(); + + this.calculator.SetOperator("*"); + this.calculator.AppendDigit("2"); + this.calculator.Calculate(); + + Assert.That(this.calculator.Display, Is.EqualTo("20")); + } +} diff --git a/HW3/CalculatorCore.Test/stylecop.json b/HW3/CalculatorCore.Test/stylecop.json new file mode 100644 index 0000000..76c8e76 --- /dev/null +++ b/HW3/CalculatorCore.Test/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/HW3/CalculatorCore/CalculatorCore.csproj b/HW3/CalculatorCore/CalculatorCore.csproj new file mode 100644 index 0000000..40abd96 --- /dev/null +++ b/HW3/CalculatorCore/CalculatorCore.csproj @@ -0,0 +1,23 @@ + + + + WinExe + net8.0-windows + true + enable + enable + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/HW3/CalculatorCore/CalculatorLogic.cs b/HW3/CalculatorCore/CalculatorLogic.cs new file mode 100644 index 0000000..88fa6d3 --- /dev/null +++ b/HW3/CalculatorCore/CalculatorLogic.cs @@ -0,0 +1,292 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace CalculatorCore; + +using System.ComponentModel; +using System.Globalization; +using System.Runtime.CompilerServices; + +/// +/// Handles the calculator's core logic: input processing, arithmetic operations, and display updates. +/// +public class CalculatorLogic : INotifyPropertyChanged +{ + private double? currentValue; + private string? pendingOperator; + private string? display = "0"; + private bool isNewInput = true; + private string inputBuffer = string.Empty; + private bool hasError; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Gets the value shown on the calculator screen. + /// Automatically updates the UI when changed. + /// + public string? Display + { + get => this.display; + private set + { + if (this.display == value) + { + return; + } + + this.display = value; + this.OnPropertyChanged(); + } + } + + /// + /// Appends a digit or a decimal point to the current number. + /// + /// The digit (0–9) or decimal point ("."). + public void AppendDigit(string? digit) + { + if (this.hasError) + { + this.Clear(); + } + + if (digit is "," or ".") + { + digit = ","; + } + + if (digit != "," && !char.IsDigit(digit ?? string.Empty, 0)) + { + return; + } + + if (this.isNewInput) + { + this.inputBuffer = string.Empty; + this.isNewInput = false; + } + + switch (digit) + { + case "," when this.inputBuffer.Contains(','): + return; + case "," when string.IsNullOrEmpty(this.inputBuffer): + this.inputBuffer = "0,"; + break; + default: + this.inputBuffer += digit; + break; + } + + this.Display = this.inputBuffer.Length > 0 ? this.inputBuffer : "0"; + } + + /// + /// Sets the operator (+, -, *, /) and, if possible, performs the intermediate calculation. + /// + /// The operator symbol. + public void SetOperator(string @operator) + { + if (this.hasError) + { + this.Clear(); + } + + if (@operator != "+" && @operator != "-" && @operator != "*" && @operator != "/") + { + return; + } + + if (!string.IsNullOrEmpty(this.inputBuffer)) + { + if (!TryParseInput(this.inputBuffer, out var number)) + { + this.DisplayError(); + return; + } + + if (this.currentValue == null) + { + this.currentValue = number; + } + else if (this.pendingOperator != null) + { + if (!TryCalculate(this.currentValue.Value, number, this.pendingOperator, out var result)) + { + this.DisplayError(); + return; + } + + this.currentValue = result; + this.Display = ConvertToDisplay(result); + } + + this.inputBuffer = string.Empty; + this.isNewInput = true; + } + + this.pendingOperator = @operator; + this.Display = this.currentValue != null ? ConvertToDisplay(this.currentValue.Value) : "0"; + } + + /// + /// Performs the final calculation and updates the display. + /// + public void Calculate() + { + if (this.hasError) + { + return; + } + + if (this.pendingOperator == null || string.IsNullOrEmpty(this.inputBuffer)) + { + return; + } + + if (this.inputBuffer.EndsWith(',')) + { + this.inputBuffer = this.inputBuffer[..^1]; + } + + if (!TryParseInput(this.inputBuffer, out var secondNumber)) + { + this.DisplayError(); + return; + } + + if (this.currentValue == null) + { + this.currentValue = secondNumber; + this.Display = ConvertToDisplay(secondNumber); + this.inputBuffer = string.Empty; + this.isNewInput = true; + this.pendingOperator = null; + return; + } + + if (!TryCalculate(this.currentValue.Value, secondNumber, this.pendingOperator, out var result)) + { + this.DisplayError(); + return; + } + + this.currentValue = result; + this.Display = ConvertToDisplay(result); + + this.inputBuffer = string.Empty; + this.isNewInput = true; + this.pendingOperator = null; + } + + /// + /// Completely clears the calculator state. + /// + public void Clear() + { + this.currentValue = null; + this.pendingOperator = null; + this.inputBuffer = string.Empty; + this.isNewInput = true; + this.hasError = false; + this.Display = "0"; + } + + /// + /// Clears the current entry while keeping the stored result and operator. + /// + public void ClearEnter() + { + if (this.hasError) + { + this.Clear(); + return; + } + + if (this.isNewInput && string.IsNullOrEmpty(this.inputBuffer)) + { + this.currentValue = null; + this.pendingOperator = null; + this.Display = "0"; + return; + } + + this.inputBuffer = string.Empty; + this.isNewInput = true; + this.Display = this.currentValue != null ? ConvertToDisplay(this.currentValue.Value) : "0"; + } + + /// + /// Deletes the last entered digit. + /// + public void Backspace() + { + if (this.hasError) + { + this.Clear(); + return; + } + + if (this.isNewInput && string.IsNullOrEmpty(this.inputBuffer)) + { + this.inputBuffer = this.Display ?? "0"; + this.isNewInput = false; + this.currentValue = null; + this.pendingOperator = null; + } + + if (this.inputBuffer.Length <= 0) + { + this.Display = "0"; + return; + } + + this.inputBuffer = this.inputBuffer[..^1]; + this.Display = this.inputBuffer.Length > 0 ? this.inputBuffer : "0"; + } + + private static bool TryCalculate(double first, double second, string @operator, out double result) + { + try + { + result = @operator switch + { + "+" => first + second, + "-" => first - second, + "*" => first * second, + "/" => second != 0 ? first / second : throw new DivideByZeroException(), + _ => throw new InvalidOperationException("Unknown operator"), + }; + return true; + } + catch + { + result = 0; + return false; + } + } + + private static bool TryParseInput(string input, out double number) + { + return double.TryParse(input, NumberStyles.Float, new CultureInfo("ru-RU"), out number); + } + + private static string ConvertToDisplay(double number) + { + return number.ToString(new CultureInfo("ru-RU")); + } + + private void DisplayError() + { + this.Display = "Error"; + this.hasError = true; + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/HW3/CalculatorCore/MainForm.cs b/HW3/CalculatorCore/MainForm.cs new file mode 100644 index 0000000..b8da8fc --- /dev/null +++ b/HW3/CalculatorCore/MainForm.cs @@ -0,0 +1,150 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +namespace CalculatorCore; + +using System; +using System.Diagnostics.CodeAnalysis; +using System.Drawing; +using System.Windows.Forms; + +/// +/// The main WinForms form calculator. +/// +public class MainForm : Form +{ + private readonly CalculatorLogic calculatorLogic; + private readonly TextBox displayTextBox; + + /// + /// Initializes a new instance of the class. + /// Form constructor, initializes the UI and data binding. + /// + public MainForm() + { + this.calculatorLogic = new CalculatorLogic(); + this.Size = new Size(300, 400); + this.Text = "CalculatorApp"; + this.FormBorderStyle = FormBorderStyle.FixedSingle; + this.MaximizeBox = false; + + this.displayTextBox = new TextBox + { + Location = new Point(10, 10), + Size = new Size(260, 40), + Font = new Font("Arial", 20), + TextAlign = HorizontalAlignment.Right, + ReadOnly = true, + }; + this.Controls.Add(this.displayTextBox); + + this.displayTextBox.DataBindings.Add(nameof(this.Text), this.calculatorLogic, "Display", false, DataSourceUpdateMode.OnPropertyChanged); + + var buttonPanel = new TableLayoutPanel + { + Location = new Point(10, 60), + Size = new Size(260, 300), + RowCount = 5, + ColumnCount = 4, + }; + buttonPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 20)); + buttonPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 20)); + buttonPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 20)); + buttonPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 20)); + buttonPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 20)); + buttonPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); + buttonPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); + buttonPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); + buttonPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 25)); + this.Controls.Add(buttonPanel); + + this.CreateButton(buttonPanel, "7", 0, 0); + this.CreateButton(buttonPanel, "8", 0, 1); + this.CreateButton(buttonPanel, "9", 0, 2); + this.CreateButton(buttonPanel, "/", 0, 3); + + this.CreateButton(buttonPanel, "4", 1, 0); + this.CreateButton(buttonPanel, "5", 1, 1); + this.CreateButton(buttonPanel, "6", 1, 2); + this.CreateButton(buttonPanel, "*", 1, 3); + + this.CreateButton(buttonPanel, "1", 2, 0); + this.CreateButton(buttonPanel, "2", 2, 1); + this.CreateButton(buttonPanel, "3", 2, 2); + this.CreateButton(buttonPanel, "-", 2, 3); + + this.CreateButton(buttonPanel, "0", 3, 0); + this.CreateButton(buttonPanel, ".", 3, 1); + this.CreateButton(buttonPanel, "=", 3, 2); + this.CreateButton(buttonPanel, "+", 3, 3); + + this.CreateButton(buttonPanel, "C", 4, 0); + this.CreateButton(buttonPanel, "CE", 4, 1); + this.CreateButton(buttonPanel, "←", 4, 2); + } + + /// + [AllowNull] + public sealed override string Text + { + get => base.Text; + set => base.Text = value; + } + + /// + /// Creates a button and adds it to the grid, with a click handler. + /// + /// Grid for the button. + /// Button content. + /// Row in the grid. + /// Column in the grid. + private void CreateButton(TableLayoutPanel panel, string content, int row, int column) + { + var button = new Button + { + Text = content, + Font = new Font("Arial", 14), + Dock = DockStyle.Fill, + }; + button.Click += this.Button_Click!; + panel.Controls.Add(button, column, row); + } + + /// + /// Handles a button click by calling logic methods. + /// + /// Button. + /// Event arguments. + private void Button_Click(object sender, EventArgs e) + { + var button = (Button)sender; + var buttonContent = button.Text; + + if (char.IsDigit(buttonContent, 0) || buttonContent == ".") + { + this.calculatorLogic.AppendDigit(buttonContent); + } + else + { + switch (buttonContent) + { + case "+" or "-" or "*" or "/": + this.calculatorLogic.SetOperator(buttonContent); + break; + case "=": + this.calculatorLogic.Calculate(); + break; + case "C": + this.calculatorLogic.Clear(); + break; + case "CE": + this.calculatorLogic.ClearEnter(); + break; + case "←": + this.calculatorLogic.Backspace(); + break; + } + } + } +} \ No newline at end of file diff --git a/HW3/CalculatorCore/Program.cs b/HW3/CalculatorCore/Program.cs new file mode 100644 index 0000000..02ee532 --- /dev/null +++ b/HW3/CalculatorCore/Program.cs @@ -0,0 +1,9 @@ +// +// Copyright (c) khusainovilas. All rights reserved. +// + +using CalculatorCore; + +Application.EnableVisualStyles(); +Application.SetCompatibleTextRenderingDefault(false); +Application.Run(new MainForm()); \ No newline at end of file diff --git a/HW3/CalculatorCore/stylecop.json b/HW3/CalculatorCore/stylecop.json new file mode 100644 index 0000000..76c8e76 --- /dev/null +++ b/HW3/CalculatorCore/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/HW3/HW3.sln b/HW3/HW3.sln new file mode 100644 index 0000000..8c20cc0 --- /dev/null +++ b/HW3/HW3.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CalculatorCore", "CalculatorCore\CalculatorCore.csproj", "{5D070A5B-FA66-416C-B482-A5396D4A04C1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CalculatorCore.Test", "CalculatorCore.Test\CalculatorCore.Test.csproj", "{A7EE239C-A0EC-48F7-92F1-C6B5DB9973E5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5D070A5B-FA66-416C-B482-A5396D4A04C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5D070A5B-FA66-416C-B482-A5396D4A04C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5D070A5B-FA66-416C-B482-A5396D4A04C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5D070A5B-FA66-416C-B482-A5396D4A04C1}.Release|Any CPU.Build.0 = Release|Any CPU + {A7EE239C-A0EC-48F7-92F1-C6B5DB9973E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A7EE239C-A0EC-48F7-92F1-C6B5DB9973E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A7EE239C-A0EC-48F7-92F1-C6B5DB9973E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A7EE239C-A0EC-48F7-92F1-C6B5DB9973E5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal