diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 0936a2d..af2e968 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -17,7 +17,8 @@
"Bash(\"/mnt/c/Program Files/dotnet/dotnet\" build)",
"PowerShell(git diff *)",
"PowerShell(dotnet build *)",
- "Bash(gh issue list *)"
+ "Bash(gh issue list *)",
+ "Bash(dotnet test *)"
],
"deny": []
}
diff --git a/MagickCrop.Tests/Helpers/GeometryMathHelperTests.cs b/MagickCrop.Tests/Helpers/GeometryMathHelperTests.cs
new file mode 100644
index 0000000..cd2447e
--- /dev/null
+++ b/MagickCrop.Tests/Helpers/GeometryMathHelperTests.cs
@@ -0,0 +1,114 @@
+using System.Windows;
+using MagickCrop.Helpers;
+
+namespace MagickCrop.Tests.Helpers;
+
+[TestClass]
+public class GeometryMathHelperTests
+{
+ [TestMethod]
+ public void MidPoint_ReturnsAverageOfCoordinates()
+ {
+ Point result = GeometryMathHelper.MidPoint(new Point(0, 0), new Point(10, 20));
+
+ Assert.AreEqual(5, result.X);
+ Assert.AreEqual(10, result.Y);
+ }
+
+ [TestMethod]
+ public void Distance_ForHorizontalSegment_ReturnsDeltaX()
+ {
+ double result = GeometryMathHelper.Distance(new Point(0, 0), new Point(3, 0));
+
+ Assert.AreEqual(3, result, 1e-9);
+ }
+
+ [TestMethod]
+ public void Distance_For3_4_5Triangle_ReturnsFive()
+ {
+ double result = GeometryMathHelper.Distance(new Point(0, 0), new Point(3, 4));
+
+ Assert.AreEqual(5, result, 1e-9);
+ }
+
+ [TestMethod]
+ public void PolygonPerimeter_ClosedTriangle_SumsAllThreeEdges()
+ {
+ Point[] triangle = [new(0, 0), new(4, 0), new(0, 3)];
+
+ double result = GeometryMathHelper.PolygonPerimeter(triangle, isClosed: true);
+
+ // 4 + 5 + 3 = 12
+ Assert.AreEqual(12, result, 1e-9);
+ }
+
+ [TestMethod]
+ public void PolygonPerimeter_OpenPolyline_ExcludesClosingEdge()
+ {
+ Point[] triangle = [new(0, 0), new(4, 0), new(0, 3)];
+
+ double result = GeometryMathHelper.PolygonPerimeter(triangle, isClosed: false);
+
+ // 4 + 5 = 9 (no closing edge back to the start)
+ Assert.AreEqual(9, result, 1e-9);
+ }
+
+ [TestMethod]
+ public void PolygonPerimeter_FewerThanTwoVertices_ReturnsZero()
+ {
+ Assert.AreEqual(0, GeometryMathHelper.PolygonPerimeter([], isClosed: true));
+ Assert.AreEqual(0, GeometryMathHelper.PolygonPerimeter([new Point(1, 1)], isClosed: true));
+ }
+
+ [TestMethod]
+ public void PolygonArea_UnitSquare_ReturnsOne()
+ {
+ Point[] square = [new(0, 0), new(1, 0), new(1, 1), new(0, 1)];
+
+ double result = GeometryMathHelper.PolygonArea(square);
+
+ Assert.AreEqual(1, result, 1e-9);
+ }
+
+ [TestMethod]
+ public void PolygonArea_FewerThanThreeVertices_ReturnsZero()
+ {
+ Point[] segment = [new(0, 0), new(1, 1)];
+
+ Assert.AreEqual(0, GeometryMathHelper.PolygonArea(segment));
+ }
+
+ [TestMethod]
+ public void TryGetCircumcircle_ForPointsOnUnitCircle_FindsOriginAndRadiusOne()
+ {
+ bool found = GeometryMathHelper.TryGetCircumcircle(
+ new Point(1, 0), new Point(0, 1), new Point(-1, 0), out Point center, out double radius);
+
+ Assert.IsTrue(found);
+ Assert.AreEqual(0, center.X, 1e-9);
+ Assert.AreEqual(0, center.Y, 1e-9);
+ Assert.AreEqual(1, radius, 1e-9);
+ }
+
+ [TestMethod]
+ public void TryGetCircumcircle_ForCollinearPoints_ReturnsFalse()
+ {
+ bool found = GeometryMathHelper.TryGetCircumcircle(
+ new Point(0, 0), new Point(1, 1), new Point(2, 2), out _, out _);
+
+ Assert.IsFalse(found);
+ }
+
+ [TestMethod]
+ public void BezierControlFromPassThrough_ForMidpointOnStraightLine_ReturnsThatSamePoint()
+ {
+ Point start = new(0, 0);
+ Point end = new(10, 0);
+ Point mid = new(5, 0);
+
+ Point control = GeometryMathHelper.BezierControlFromPassThrough(start, mid, end);
+
+ Assert.AreEqual(5, control.X, 1e-9);
+ Assert.AreEqual(0, control.Y, 1e-9);
+ }
+}
diff --git a/MagickCrop.Tests/Helpers/MeasurementFormattingHelperTests.cs b/MagickCrop.Tests/Helpers/MeasurementFormattingHelperTests.cs
new file mode 100644
index 0000000..cdeff9c
--- /dev/null
+++ b/MagickCrop.Tests/Helpers/MeasurementFormattingHelperTests.cs
@@ -0,0 +1,39 @@
+using MagickCrop.Helpers;
+
+namespace MagickCrop.Tests.Helpers;
+
+[TestClass]
+public class MeasurementFormattingHelperTests
+{
+ [TestMethod]
+ public void FormatPerimeter_IncludesUnitsAndTwoDecimalPlaces()
+ {
+ string result = MeasurementFormattingHelper.FormatPerimeter(12.3456, "cm");
+
+ Assert.AreEqual("P: 12.35 cm", result);
+ }
+
+ [TestMethod]
+ public void FormatPerimeterArea_SquaresTheLinearUnits()
+ {
+ string result = MeasurementFormattingHelper.FormatPerimeterArea(10, 6.25, "cm");
+
+ Assert.AreEqual("P: 10.00 cm, A: 6.25 cm²", result);
+ }
+
+ [TestMethod]
+ public void FormatNeedMorePoints_IncludesRemainingCount()
+ {
+ string result = MeasurementFormattingHelper.FormatNeedMorePoints(4.5, "px", 2);
+
+ Assert.AreEqual("P: 4.50 px (Need 2 more points)", result);
+ }
+
+ [TestMethod]
+ public void FormatClickToClose_IncludesInstructionalText()
+ {
+ string result = MeasurementFormattingHelper.FormatClickToClose(4.5, "px");
+
+ Assert.AreEqual("P: 4.50 px (Click orange point to close)", result);
+ }
+}
diff --git a/MagickCrop.Tests/MSTestSettings.cs b/MagickCrop.Tests/MSTestSettings.cs
new file mode 100644
index 0000000..aaf278c
--- /dev/null
+++ b/MagickCrop.Tests/MSTestSettings.cs
@@ -0,0 +1 @@
+[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)]
diff --git a/MagickCrop.Tests/MagickCrop.Tests.csproj b/MagickCrop.Tests/MagickCrop.Tests.csproj
new file mode 100644
index 0000000..2896d84
--- /dev/null
+++ b/MagickCrop.Tests/MagickCrop.Tests.csproj
@@ -0,0 +1,40 @@
+
+
+
+ net10.0-windows10.0.20348.0
+ latest
+ enable
+ enable
+ true
+ false
+ true
+ x64;ARM64
+
+ ARM64
+ x64
+ $(Platform)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Platform=$(Platform)
+
+
+
+
diff --git a/MagickCrop.Tests/UndoRedoTests.cs b/MagickCrop.Tests/UndoRedoTests.cs
new file mode 100644
index 0000000..9469f69
--- /dev/null
+++ b/MagickCrop.Tests/UndoRedoTests.cs
@@ -0,0 +1,111 @@
+using MagickCrop;
+
+namespace MagickCrop.Tests;
+
+[TestClass]
+public class UndoRedoTests
+{
+ private sealed class FakeUndoRedoItem : UndoRedoItem
+ {
+ public int UndoCalls { get; private set; }
+ public int RedoCalls { get; private set; }
+ public string UndoResult { get; set; } = string.Empty;
+ public string RedoResult { get; set; } = string.Empty;
+
+ public override string Undo()
+ {
+ UndoCalls++;
+ return UndoResult;
+ }
+
+ public override string Redo()
+ {
+ RedoCalls++;
+ return RedoResult;
+ }
+ }
+
+ [TestMethod]
+ public void NewUndoRedo_HasNothingToUndoOrRedo()
+ {
+ UndoRedo undoRedo = new();
+
+ Assert.IsFalse(undoRedo.CanUndo);
+ Assert.IsFalse(undoRedo.CanRedo);
+ }
+
+ [TestMethod]
+ public void AddUndo_MakesCanUndoTrueAndCanRedoFalse()
+ {
+ UndoRedo undoRedo = new();
+
+ undoRedo.AddUndo(new FakeUndoRedoItem());
+
+ Assert.IsTrue(undoRedo.CanUndo);
+ Assert.IsFalse(undoRedo.CanRedo);
+ }
+
+ [TestMethod]
+ public void Undo_InvokesItemUndoAndMovesItToRedoStack()
+ {
+ UndoRedo undoRedo = new();
+ FakeUndoRedoItem item = new() { UndoResult = "previous.png" };
+ undoRedo.AddUndo(item);
+
+ string result = undoRedo.Undo();
+
+ Assert.AreEqual("previous.png", result);
+ Assert.AreEqual(1, item.UndoCalls);
+ Assert.IsFalse(undoRedo.CanUndo);
+ Assert.IsTrue(undoRedo.CanRedo);
+ }
+
+ [TestMethod]
+ public void Redo_AfterUndo_InvokesItemRedoAndMovesItBackToUndoStack()
+ {
+ UndoRedo undoRedo = new();
+ FakeUndoRedoItem item = new() { RedoResult = "next.png" };
+ undoRedo.AddUndo(item);
+ undoRedo.Undo();
+
+ string result = undoRedo.Redo();
+
+ Assert.AreEqual("next.png", result);
+ Assert.AreEqual(1, item.RedoCalls);
+ Assert.IsTrue(undoRedo.CanUndo);
+ Assert.IsFalse(undoRedo.CanRedo);
+ }
+
+ [TestMethod]
+ public void Undo_WithEmptyStack_ReturnsEmptyStringAndDoesNotThrow()
+ {
+ UndoRedo undoRedo = new();
+
+ string result = undoRedo.Undo();
+
+ Assert.AreEqual(string.Empty, result);
+ }
+
+ [TestMethod]
+ public void Redo_WithEmptyStack_ReturnsEmptyStringAndDoesNotThrow()
+ {
+ UndoRedo undoRedo = new();
+
+ string result = undoRedo.Redo();
+
+ Assert.AreEqual(string.Empty, result);
+ }
+
+ [TestMethod]
+ public void AddUndo_AfterAnUndo_ClearsTheRedoStack()
+ {
+ UndoRedo undoRedo = new();
+ undoRedo.AddUndo(new FakeUndoRedoItem());
+ undoRedo.Undo();
+ Assert.IsTrue(undoRedo.CanRedo);
+
+ undoRedo.AddUndo(new FakeUndoRedoItem());
+
+ Assert.IsFalse(undoRedo.CanRedo);
+ }
+}
diff --git a/MagickCrop.sln b/MagickCrop.sln
index ee4250d..040a892 100644
--- a/MagickCrop.sln
+++ b/MagickCrop.sln
@@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MagickCrop", "MagickCrop\Ma
EndProject
Project("{C7167F0D-BC9F-4E6E-AFE1-012C56B48DB5}") = "MagickCrop-Package", "MagickCrop-Package\MagickCrop-Package.wapproj", "{F6C2CFAD-9A9B-48F6-A5F3-DB3BCC25CACB}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MagickCrop.Tests", "MagickCrop.Tests\MagickCrop.Tests.csproj", "{113A3DCE-5333-4003-9B94-61C46F5BB543}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -71,6 +73,26 @@ Global
{F6C2CFAD-9A9B-48F6-A5F3-DB3BCC25CACB}.Release|x86.ActiveCfg = Release|x86
{F6C2CFAD-9A9B-48F6-A5F3-DB3BCC25CACB}.Release|x86.Build.0 = Release|x86
{F6C2CFAD-9A9B-48F6-A5F3-DB3BCC25CACB}.Release|x86.Deploy.0 = Release|x86
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|Any CPU.ActiveCfg = Debug|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|Any CPU.Build.0 = Debug|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|ARM.ActiveCfg = Debug|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|ARM.Build.0 = Debug|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|ARM64.ActiveCfg = Debug|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|ARM64.Build.0 = Debug|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|x64.ActiveCfg = Debug|x64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|x64.Build.0 = Debug|x64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|x86.ActiveCfg = Debug|x64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Debug|x86.Build.0 = Debug|x64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|Any CPU.ActiveCfg = Release|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|Any CPU.Build.0 = Release|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|ARM.ActiveCfg = Release|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|ARM.Build.0 = Release|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|ARM64.ActiveCfg = Release|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|ARM64.Build.0 = Release|ARM64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|x64.ActiveCfg = Release|x64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|x64.Build.0 = Release|x64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|x86.ActiveCfg = Release|x64
+ {113A3DCE-5333-4003-9B94-61C46F5BB543}.Release|x86.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/MagickCrop/Controls/AngleMeasurementControl.xaml b/MagickCrop/Controls/AngleMeasurementControl.xaml
index 83a3e77..aad3552 100644
--- a/MagickCrop/Controls/AngleMeasurementControl.xaml
+++ b/MagickCrop/Controls/AngleMeasurementControl.xaml
@@ -85,6 +85,10 @@
Click="CopyMeasurementMenuItem_Click"
Header="Copy Measurement"
ToolTip="Copy the angle value" />
+