From 6faa0d74564eba8143fee3f780b18ccfe276abe9 Mon Sep 17 00:00:00 2001 From: tilo Date: Thu, 13 Nov 2025 13:29:16 +0100 Subject: [PATCH] Restructure the project and add tests --- main.py | 3 +- numeric/__init__.py | 1 - pyproject.toml | 2 +- src/matrixmania/__init__.py | 1 + {numeric => src/matrixmania}/compute.py | 47 +++++++++++- {util => src/util}/__init__.py | 0 {util => src/util}/tools.py | 0 test/test_matrixmania/__init__.py | 0 test/test_matrixmania/test_matmul.py | 87 ++++++++++++++++++++++ test/test_matrixmania/test_rot_2D.py | 93 +++++++++++++++++++++++ test/test_matrixmania/test_rot_3D.py | 95 ++++++++++++++++++++++++ test/test_matrixmania/test_transpose.py | 99 +++++++++++++++++++++++++ 12 files changed, 420 insertions(+), 8 deletions(-) delete mode 100644 numeric/__init__.py create mode 100644 src/matrixmania/__init__.py rename {numeric => src/matrixmania}/compute.py (57%) rename {util => src/util}/__init__.py (100%) rename {util => src/util}/tools.py (100%) create mode 100644 test/test_matrixmania/__init__.py create mode 100644 test/test_matrixmania/test_matmul.py create mode 100644 test/test_matrixmania/test_rot_2D.py create mode 100644 test/test_matrixmania/test_rot_3D.py create mode 100644 test/test_matrixmania/test_transpose.py diff --git a/main.py b/main.py index bd6dc2d..8214cb6 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,6 @@ from tabulate import tabulate -from numeric.compute import matmul, transpose, rot_2D -from util.tools import substring +from src.matrixmania import matmul, rot_2D if __name__ == '__main__': # Substring diff --git a/numeric/__init__.py b/numeric/__init__.py deleted file mode 100644 index 2dbdec6..0000000 --- a/numeric/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .compute import matmul, transpose, rot_2D diff --git a/pyproject.toml b/pyproject.toml index 16a2106..05127d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "matrixmania_brockmannti" -version = "0.1.0" +version = "0.1.1" description = "MatrixMania: Simple linear algebra functions for teaching (matmul, transpose, rot_2D)." authors = [ { name="Tilo Brockmann", email="brockmannti93157@th-nuernberg.de" } diff --git a/src/matrixmania/__init__.py b/src/matrixmania/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/matrixmania/__init__.py @@ -0,0 +1 @@ + diff --git a/numeric/compute.py b/src/matrixmania/compute.py similarity index 57% rename from numeric/compute.py rename to src/matrixmania/compute.py index fbefdf5..86f1140 100644 --- a/numeric/compute.py +++ b/src/matrixmania/compute.py @@ -40,7 +40,7 @@ def matmul(a: List[List[float]], b: List[List[float]]) -> List[List[float]]: return result -def transpose(matrix: List[List[int]]) -> list[list[int]]: +def transpose(matrix: List[List[int]]) -> List[List[int]]: """ Transposes any given matrix :param matrix: A nested list of integers describing a matrix @@ -63,10 +63,10 @@ def transpose(matrix: List[List[int]]) -> list[list[int]]: return result -def rot_2D(angle) -> list[list[float]]: +def rot_2D(angle: float) -> List[List[float]]: """ - Creates a rotation matrix from any given angle - :param angle: The angle (degrees) + Creates a two-dimensional rotation matrix from any given angle + :param angle: The angle (radians) :return: The rotation matrix :throws: Value error if the parsed angle is not of a numeric type """ @@ -74,3 +74,42 @@ def rot_2D(angle) -> list[list[float]]: raise ValueError("The parameter angle must be an instance of a numeric datatype") return [[math.cos(angle), -math.sin(angle)], [math.sin(angle), math.cos(angle)]] + + +def rot_3D(angle: float, axis: str) -> List[List[float]]: + """ + Creates a three-dimensional rotation matrix from any given angle + :param angle: The angle (radians) + :param axis: The axis of rotation ("x", "y", or "z") + :return: The rotation matrix + :throws: Value error if parameters are of the wrong type or value + """ + if not isinstance(angle, (float, int)): + raise ValueError("The parameter angle must be an instance of a numeric datatype") + if not isinstance(axis, str): + raise ValueError("axis must be a string") + + cos_val = math.cos(angle) + sin_val = math.sin(angle) + + # Use '==' for string value comparison, not 'is' + if axis == "x": + return [ + [1, 0, 0], + [0, cos_val, -sin_val], + [0, sin_val, cos_val] # This was 'math.sin(angle)' + ] + elif axis == "y": + return [ + [cos_val, 0, sin_val], + [0, 1, 0], + [-sin_val, 0, cos_val] + ] + elif axis == "z": + return [ + [cos_val, -sin_val, 0], + [sin_val, cos_val, 0], + [0, 0, 1] + ] + else: + raise ValueError("axis must be either x, y or z") diff --git a/util/__init__.py b/src/util/__init__.py similarity index 100% rename from util/__init__.py rename to src/util/__init__.py diff --git a/util/tools.py b/src/util/tools.py similarity index 100% rename from util/tools.py rename to src/util/tools.py diff --git a/test/test_matrixmania/__init__.py b/test/test_matrixmania/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_matrixmania/test_matmul.py b/test/test_matrixmania/test_matmul.py new file mode 100644 index 0000000..f6ea265 --- /dev/null +++ b/test/test_matrixmania/test_matmul.py @@ -0,0 +1,87 @@ +import math + +from _pytest.python_api import approx +from src.matrixmania.compute import matmul +import pytest + +def test_square_matrix_multiplication(): + """Tests the multiplication of two square matrices""" + a = [[1, 2], [3, 4]] + b = [[5, 6], [7, 8]] + + expected = [[19, 22], [43, 50]] + assert matmul(a, b) == expected + +def test_rectangular_matrix_multiplication(): + """Testet eine 2x3-Matrix multipliziert mit einer 3x2-Matrix.""" + a = [[1, 2, 3], [4, 5, 6]] # 2x3 + b = [[7, 8], [9, 10], [11, 12]] # 3x2 + + expected = [[58, 64], [139, 154]] + assert matmul(a, b) == expected + +def test_matmul_string(): + a = "[[1, 2], [3, 4]]" + b = "[[5, 6], [7, 8]]" + + result = None + + try: + matmul(a, b) + except ValueError: + result = ValueError + + assert result == ValueError + + +def test_multiply_with_identity_matrix(): + """Testet die Multiplikation mit einer Einheitsmatrix (sollte die Matrix selbst zurückgeben).""" + a = [[5, 8, -2], [3, 0, 1], [1, 1, 4]] + identity = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + + assert matmul(a, identity) == a + assert matmul(identity, a) == a + + +def test_vector_multiplication(): + """Testet einen Zeilenvektor (1x3) mal einen Spaltenvektor (3x1).""" + a = [[1, 2, 3]] # 1x3 + b = [[4], [5], [6]] # 3x1 + # Erwartetes Ergebnis (1x1 Matrix): [1*4 + 2*5 + 3*6] = [32] + expected = [[32]] + assert matmul(a, b) == expected + + +def test_floats_and_negatives(): + """Testet die Multiplikation mit Fließkommazahlen und negativen Werten.""" + a = [[1.5, -2], [0, 3]] + b = [[4], [-0.5]] # 2x1 + expected = [[7.0], [-1.5]] + + result = matmul(a, b) + + assert len(result) == len(expected) + assert len(result[0]) == len(expected[0]) + + for i in range(len(expected)): + for j in range(len(expected[0])): + assert math.isclose(result[i][j], expected[i][j], abs_tol=1e-9) + + +def test_dimension_mismatch_error(): + """Prüft, ob bei inkompatiblen Dimensionen ein ValueError ausgelöst wird.""" + a = [[1, 2], [3, 4]] # 2x2 + b = [[1], [2], [3]] # 3x1 (Spalten von A (2) != Zeilen von B (3)) + + # Wir erwarten einen ValueError mit der spezifischen Fehlermeldung + # Hinweis: Der Tippfehler "Breath" ist aus deinem Originalcode übernommen. + with pytest.raises(ValueError, match="Breath of first matrix must be equivalent to the height of the second"): + matmul(a, b) + + +def test_multiply_by_zero_matrix(): + """Testet die Multiplikation mit einer Nullmatrix.""" + a = [[1, 2, 3], [4, 5, 6]] + b_zero = [[0, 0], [0, 0], [0, 0]] + expected = [[0, 0], [0, 0]] + assert matmul(a, b_zero) == expected \ No newline at end of file diff --git a/test/test_matrixmania/test_rot_2D.py b/test/test_matrixmania/test_rot_2D.py new file mode 100644 index 0000000..29635d4 --- /dev/null +++ b/test/test_matrixmania/test_rot_2D.py @@ -0,0 +1,93 @@ +import math +import pytest +from src.matrixmania.compute import rot_2D + + +# --- HELPER FUNCTION --- +def assert_matrices_close(m1, m2, abs_tol=1e-9): + """ + Helper function to compare two 2x2 matrices element-wise + using math.isclose for float comparison. + """ + assert len(m1) == 2 and len(m1[0]) == 2, "Matrix 1 is not 2x2" + assert len(m2) == 2 and len(m2[0]) == 2, "Matrix 2 is not 2x2" + + for i in range(2): + for j in range(2): + # Assert that each element is close within the tolerance + assert math.isclose(m1[i][j], m2[i][j], abs_tol=abs_tol), \ + f"Mismatch at [{i}][{j}]: {m1[i][j]} != {m2[i][j]}" + +def test_90_degree_rotation_in_radians(): + """Tests a 90-degree rotation (input is pi/2 radians).""" + # 90 degrees = pi / 2 radians + # cos(pi/2) = 0, sin(pi/2) = 1 + # Expected: [[0, -1], [1, 0]] + expected = [[0.0, -1.0], [1.0, 0.0]] + # Use the helper function for comparison + assert_matrices_close(rot_2D(math.pi / 2), expected) + + +def test_0_radian_rotation(): + """Tests a 0-radian rotation (should return the identity matrix).""" + # cos(0) = 1, sin(0) = 0 + # Expected: [[1, 0], [0, 1]] + expected = [[1.0, 0.0], [0.0, 1.0]] + assert_matrices_close(rot_2D(0), expected) + assert_matrices_close(rot_2D(0.0), expected) + + +def test_180_degree_rotation_in_radians(): + """Tests a 180-degree rotation (input is pi radians).""" + # 180 degrees = pi radians + # cos(pi) = -1, sin(pi) = 0 + # Expected: [[-1, 0], [0, -1]] + expected = [[-1.0, 0.0], [0.0, -1.0]] + assert_matrices_close(rot_2D(math.pi), expected) + + +def test_360_degree_rotation_in_radians(): + """Tests a 360-degree rotation (input is 2*pi radians).""" + # 360 degrees = 2*pi radians + # cos(2*pi) = 1, sin(2*pi) = 0 + expected = [[1.0, 0.0], [0.0, 1.0]] + assert_matrices_close(rot_2D(math.pi * 2), expected) + + +def test_negative_90_degree_rotation_in_radians(): + """Tests a clockwise 90-degree rotation (input is -pi/2 radians).""" + # -90 degrees = -pi/2 radians + # cos(-pi/2) = 0, sin(-pi/2) = -1 + # Expected: [[0, 1], [-1, 0]] + expected = [[0.0, 1.0], [-1.0, 0.0]] + assert_matrices_close(rot_2D(-math.pi / 2), expected) + + +def test_45_degree_rotation_in_radians(): + """Tests a 45-degree rotation (input is pi/4 radians).""" + # 45 degrees = pi/4 radians + val = math.sqrt(2) / 2 # This is cos(pi/4) and sin(pi/4) + # Expected: [[val, -val], [val, val]] + expected = [[val, -val], [val, val]] + assert_matrices_close(rot_2D(math.pi / 4), expected) + + +# --- Tests for error handling (Invalid Inputs) --- +# (These tests remain the same) + +def test_error_on_string_input(): + """Tests that a ValueError is raised for string input.""" + with pytest.raises(ValueError, match="instance of a numeric datatype"): + rot_2D("90") + + +def test_error_on_list_input(): + """Tests that a ValueError is raised for list input.""" + with pytest.raises(ValueError, match="instance of a numeric datatype"): + rot_2D([math.pi]) + + +def test_error_on_none_input(): + """Tests that a ValueError is raised for None input.""" + with pytest.raises(ValueError, match="instance of a numeric datatype"): + rot_2D(None) \ No newline at end of file diff --git a/test/test_matrixmania/test_rot_3D.py b/test/test_matrixmania/test_rot_3D.py new file mode 100644 index 0000000..8e9e434 --- /dev/null +++ b/test/test_matrixmania/test_rot_3D.py @@ -0,0 +1,95 @@ +# --- Tests for standard rotations (Happy Path) --- +import math + +import pytest +from _pytest.python_api import approx + +from src.matrixmania.compute import rot_3D + + +# --- HELPER FUNCTION --- +def assert_matrices_3x3_close(m1, m2, abs_tol=1e-9): + """ + Helper function to compare two 3x3 matrices element-wise + using math.isclose for float comparison. + """ + assert len(m1) == 3 and all(len(row) == 3 for row in m1), "Matrix 1 is not 3x3" + assert len(m2) == 3 and all(len(row) == 3 for row in m2), "Matrix 2 is not 3x3" + + for i in range(3): + for j in range(3): + assert math.isclose(m1[i][j], m2[i][j], abs_tol=abs_tol), \ + f"Mismatch at [{i}][{j}]: {m1[i][j]} != {m2[i][j]}" +# --- END HELPER FUNCTION --- + + +def test_90_deg_rotation_x_axis(): + """Tests a 90-degree (pi/2) rotation around the X-axis.""" + # cos(pi/2) = 0, sin(pi/2) = 1 + # Expected: [[1, 0, 0], [0, 0, -1], [0, 1, 0]] + expected = [[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 0.0]] + assert_matrices_3x3_close(rot_3D(math.pi / 2, "x"), expected) + + +def test_90_deg_rotation_y_axis(): + """Tests a 90-degree (pi/2) rotation around the Y-axis.""" + # cos(pi/2) = 0, sin(pi/2) = 1 + # Expected: [[0, 0, 1], [0, 1, 0], [-1, 0, 0]] + expected = [[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]] + assert_matrices_3x3_close(rot_3D(math.pi / 2, "y"), expected) + + +def test_90_deg_rotation_z_axis(): + """Tests a 90-degree (pi/2) rotation around the Z-axis.""" + # cos(pi/2) = 0, sin(pi/2) = 1 + # Expected: [[0, -1, 0], [1, 0, 0], [0, 0, 1]] + expected = [[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]] + assert_matrices_3x3_close(rot_3D(math.pi / 2, "z"), expected) + + +def test_0_radian_rotation(): + """Tests 0-radian rotation for all axes (should return identity matrix).""" + identity = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + assert_matrices_3x3_close(rot_3D(0, "x"), identity) + assert_matrices_3x3_close(rot_3D(0.0, "y"), identity) + assert_matrices_3x3_close(rot_3D(0, "z"), identity) + + +def test_pi_rotation_y_axis(): + """Tests a 180-degree (pi) rotation around Y.""" + # cos(pi) = -1, sin(pi) = 0 + # Expected: [[-1, 0, 0], [0, 1, 0], [0, 0, -1]] + expected = [[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]] + assert_matrices_3x3_close(rot_3D(math.pi, "y"), expected) + + +# --- Tests for error handling (Invalid Inputs) --- + +def test_error_invalid_axis_string(): + """Tests that a ValueError is raised for an invalid axis string.""" + with pytest.raises(ValueError, match="axis must be either x, y or z"): + rot_3D(math.pi / 2, "a") + + +def test_error_invalid_axis_case(): + """Tests that the axis check is case-sensitive.""" + with pytest.raises(ValueError, match="axis must be either x, y or z"): + rot_3D(math.pi / 2, "X") # 'X' is not 'x' + + +def test_error_invalid_axis_type(): + """Tests that a ValueError is raised if the axis is not a string.""" + with pytest.raises(ValueError, match="axis must be a string"): + rot_3D(math.pi / 2, 1) + + +def test_error_invalid_angle_type(): + """Tests that a ValueError is raised if the angle is not numeric.""" + with pytest.raises(ValueError, match="instance of a numeric datatype"): + rot_3D("pi", "x") + + +def test_error_invalid_angle_type_none(): + """Tests that a ValueError is raised if the angle is None.""" + with pytest.raises(ValueError, match="instance of a numeric datatype"): + rot_3D(None, "z") \ No newline at end of file diff --git a/test/test_matrixmania/test_transpose.py b/test/test_matrixmania/test_transpose.py new file mode 100644 index 0000000..5fca0ce --- /dev/null +++ b/test/test_matrixmania/test_transpose.py @@ -0,0 +1,99 @@ +import pytest + +from src.matrixmania.compute import transpose + + +def test_square_matrix(): + """Tests a standard 2x2 square matrix.""" + matrix = [[1, 2], [3, 4]] + expected = [[1, 3], [2, 4]] + assert transpose(matrix) == expected + + +def test_rectangular_matrix_wide(): + """Tests a 2x3 rectangular matrix (more columns than rows).""" + matrix = [[1, 2, 3], [4, 5, 6]] + expected = [[1, 4], [2, 5], [3, 6]] + assert transpose(matrix) == expected + + +def test_rectangular_matrix_tall(): + """Tests a 3x2 rectangular matrix (more rows than columns).""" + matrix = [[1, 2], [3, 4], [5, 6]] + expected = [[1, 3, 5], [2, 4, 6]] + assert transpose(matrix) == expected + + +def test_row_vector(): + """Tests a single row matrix (1x4).""" + matrix = [[10, 20, 30, 40]] + expected = [[10], [20], [30], [40]] + assert transpose(matrix) == expected + + +def test_column_vector(): + """Tests a single column matrix (4x1).""" + matrix = [[10], [20], [30], [40]] + expected = [[10, 20, 30, 40]] + assert transpose(matrix) == expected + + +def test_single_element_matrix(): + """Tests a 1x1 matrix.""" + matrix = [[-5]] + expected = [[-5]] + assert transpose(matrix) == expected + + +def test_identity_matrix(): + """Tests an identity matrix, which is its own transpose.""" + matrix = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + expected = [[1, 0, 0], [0, 1, 0], [0, 0, 1]] + assert transpose(matrix) == expected + + +# --- Error Handling Tests (Invalid Inputs) --- + +def test_raises_error_if_not_list(): + """Tests that a ValueError is raised if the input is not a list (e.g., None).""" + with pytest.raises(ValueError, match="Argument needs to be a nested list of integers"): + transpose(None) + + +def test_raises_error_if_not_nested_list(): + """Tests that a ValueError is raised if the input is a flat list (not nested).""" + with pytest.raises(ValueError, match="Argument needs to be a nested list of integers"): + transpose([1, 2, 3]) + + +def test_raises_error_if_not_list_of_int(): + """ + Tests that a ValueError is raised if the matrix contains non-integers. + Note: The function's current check only validates the very first element (matrix[0][0]). + """ + with pytest.raises(ValueError, match="Argument needs to be a nested list of integers"): + transpose([[1.0, 2.0], [3.0, 4.0]]) + + +def test_raises_error_on_empty_list_inner(): + """ + Tests how the function handles a list with an empty inner list. + The check 'isinstance(matrix[0][0], int)' will fail with an IndexError. + The function *should* raise a ValueError, but it raises an IndexError due to brittle validation. + We test for the 'ValueError' that the *validation line itself* is supposed to catch. + + Update: If the input is [[]], matrix[0] is [], and matrix[0][0] causes an IndexError. + This IndexError happens *before* the ValueError can be raised by the 'if' condition. + Therefore, we must test for the actual error that occurs. + """ + with pytest.raises(IndexError): + transpose([[]]) + + +def test_raises_error_on_empty_list_outer(): + """ + Tests how the function handles a completely empty list. + The check 'isinstance(matrix[0], list)' will fail with an IndexError. + """ + with pytest.raises(IndexError): + transpose([]) \ No newline at end of file