diff --git a/src/PyVector.cpp b/src/PyVector.cpp index acb60c0..c8e92c6 100644 --- a/src/PyVector.cpp +++ b/src/PyVector.cpp @@ -8,6 +8,8 @@ PyGetSetDef PyVector::getsetters[] = { MCRF_PROPERTY(x, "X coordinate of the vector (float)"), (void*)0}, {"y", (getter)PyVector::get_member, (setter)PyVector::set_member, MCRF_PROPERTY(y, "Y coordinate of the vector (float)"), (void*)1}, + {"int", (getter)PyVector::get_int, NULL, + MCRF_PROPERTY(int, "Integer tuple (floor of x and y) for use as dict keys. Read-only."), NULL}, {NULL} }; @@ -60,6 +62,13 @@ PyMethodDef PyVector::methods[] = { MCRF_DESC("Create a copy of this vector."), MCRF_RETURNS("Vector: New Vector object with same x and y values") )}, + {"floor", (PyCFunction)PyVector::floor, METH_NOARGS, + MCRF_METHOD(Vector, floor, + MCRF_SIG("()", "Vector"), + MCRF_DESC("Return a new vector with floored (integer) coordinates."), + MCRF_RETURNS("Vector: New Vector with floor(x) and floor(y)") + MCRF_NOTE("Useful for grid-based positioning. For a hashable tuple, use the .int property instead.") + )}, {NULL} }; @@ -102,6 +111,19 @@ namespace mcrfpydef { .nb_matrix_multiply = 0, .nb_inplace_matrix_multiply = 0 }; + + PySequenceMethods PyVector_as_sequence = { + .sq_length = PyVector::sequence_length, + .sq_concat = 0, + .sq_repeat = 0, + .sq_item = PyVector::sequence_item, + .was_sq_slice = 0, + .sq_ass_item = 0, + .was_sq_ass_slice = 0, + .sq_contains = 0, + .sq_inplace_concat = 0, + .sq_inplace_repeat = 0 + }; } PyVector::PyVector(sf::Vector2f target) @@ -397,29 +419,65 @@ int PyVector::bool_check(PyObject* self) PyObject* PyVector::richcompare(PyObject* left, PyObject* right, int op) { auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); - - if (!PyObject_IsInstance(left, (PyObject*)type) || !PyObject_IsInstance(right, (PyObject*)type)) { + + float left_x, left_y, right_x, right_y; + + // Extract left operand values + if (PyObject_IsInstance(left, (PyObject*)type)) { + PyVectorObject* vec = (PyVectorObject*)left; + left_x = vec->data.x; + left_y = vec->data.y; + } else if (PyTuple_Check(left) && PyTuple_Size(left) == 2) { + PyObject* x_obj = PyTuple_GetItem(left, 0); + PyObject* y_obj = PyTuple_GetItem(left, 1); + if ((PyFloat_Check(x_obj) || PyLong_Check(x_obj)) && + (PyFloat_Check(y_obj) || PyLong_Check(y_obj))) { + left_x = (float)PyFloat_AsDouble(x_obj); + left_y = (float)PyFloat_AsDouble(y_obj); + } else { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + } else { Py_INCREF(Py_NotImplemented); return Py_NotImplemented; } - - PyVectorObject* vec1 = (PyVectorObject*)left; - PyVectorObject* vec2 = (PyVectorObject*)right; - + + // Extract right operand values + if (PyObject_IsInstance(right, (PyObject*)type)) { + PyVectorObject* vec = (PyVectorObject*)right; + right_x = vec->data.x; + right_y = vec->data.y; + } else if (PyTuple_Check(right) && PyTuple_Size(right) == 2) { + PyObject* x_obj = PyTuple_GetItem(right, 0); + PyObject* y_obj = PyTuple_GetItem(right, 1); + if ((PyFloat_Check(x_obj) || PyLong_Check(x_obj)) && + (PyFloat_Check(y_obj) || PyLong_Check(y_obj))) { + right_x = (float)PyFloat_AsDouble(x_obj); + right_y = (float)PyFloat_AsDouble(y_obj); + } else { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + } else { + Py_INCREF(Py_NotImplemented); + return Py_NotImplemented; + } + bool result = false; - + switch (op) { case Py_EQ: - result = (vec1->data.x == vec2->data.x && vec1->data.y == vec2->data.y); + result = (left_x == right_x && left_y == right_y); break; case Py_NE: - result = (vec1->data.x != vec2->data.x || vec1->data.y != vec2->data.y); + result = (left_x != right_x || left_y != right_y); break; default: Py_INCREF(Py_NotImplemented); return Py_NotImplemented; } - + if (result) Py_RETURN_TRUE; else @@ -500,10 +558,54 @@ PyObject* PyVector::copy(PyVectorObject* self, PyObject* Py_UNUSED(ignored)) { auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); auto result = (PyVectorObject*)type->tp_alloc(type, 0); - + if (result) { result->data = self->data; } - + return (PyObject*)result; } + +PyObject* PyVector::floor(PyVectorObject* self, PyObject* Py_UNUSED(ignored)) +{ + auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector"); + auto result = (PyVectorObject*)type->tp_alloc(type, 0); + + if (result) { + result->data = sf::Vector2f(std::floor(self->data.x), std::floor(self->data.y)); + } + + return (PyObject*)result; +} + +// Sequence protocol implementation +Py_ssize_t PyVector::sequence_length(PyObject* self) +{ + return 2; // Vectors always have exactly 2 elements +} + +PyObject* PyVector::sequence_item(PyObject* obj, Py_ssize_t index) +{ + PyVectorObject* self = (PyVectorObject*)obj; + + // Note: Python already handles negative index normalization when sq_length is defined + // So v[-1] arrives here as index=1, v[-2] as index=0 + // Out-of-range negative indices (like v[-3]) arrive as negative values (e.g., -1) + if (index == 0) { + return PyFloat_FromDouble(self->data.x); + } else if (index == 1) { + return PyFloat_FromDouble(self->data.y); + } else { + PyErr_SetString(PyExc_IndexError, "Vector index out of range (must be 0 or 1)"); + return NULL; + } +} + +// Property: .int - returns integer tuple for use as dict keys +PyObject* PyVector::get_int(PyObject* obj, void* closure) +{ + PyVectorObject* self = (PyVectorObject*)obj; + long ix = (long)std::floor(self->data.x); + long iy = (long)std::floor(self->data.y); + return Py_BuildValue("(ll)", ix, iy); +} diff --git a/src/PyVector.h b/src/PyVector.h index 0b4dc46..42c3ee3 100644 --- a/src/PyVector.h +++ b/src/PyVector.h @@ -45,15 +45,24 @@ public: static PyObject* distance_to(PyVectorObject*, PyObject*); static PyObject* angle(PyVectorObject*, PyObject*); static PyObject* copy(PyVectorObject*, PyObject*); + static PyObject* floor(PyVectorObject*, PyObject*); + + // Sequence protocol + static Py_ssize_t sequence_length(PyObject*); + static PyObject* sequence_item(PyObject*, Py_ssize_t); + + // Additional properties + static PyObject* get_int(PyObject*, void*); static PyGetSetDef getsetters[]; static PyMethodDef methods[]; }; namespace mcrfpydef { - // Forward declare the PyNumberMethods structure + // Forward declare the PyNumberMethods and PySequenceMethods structures extern PyNumberMethods PyVector_as_number; - + extern PySequenceMethods PyVector_as_sequence; + static PyTypeObject PyVectorType = { .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, .tp_name = "mcrfpy.Vector", @@ -61,6 +70,7 @@ namespace mcrfpydef { .tp_itemsize = 0, .tp_repr = PyVector::repr, .tp_as_number = &PyVector_as_number, + .tp_as_sequence = &PyVector_as_sequence, .tp_hash = PyVector::hash, .tp_flags = Py_TPFLAGS_DEFAULT, .tp_doc = PyDoc_STR("SFML Vector Object"), diff --git a/tests/unit/test_vector_convenience.py b/tests/unit/test_vector_convenience.py new file mode 100644 index 0000000..fd36686 --- /dev/null +++ b/tests/unit/test_vector_convenience.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Unit tests for Vector convenience features (Issue #109) + +Tests: +- Sequence protocol: indexing, negative indexing, iteration, unpacking +- Tuple comparison: Vector == tuple, Vector != tuple +- Integer conversion: .floor() method, .int property +- Boolean check: falsey for (0, 0) +""" + +import mcrfpy +import sys + +def approx(a, b, epsilon=1e-5): + """Check if two floats are approximately equal (handles float32 precision)""" + return abs(a - b) < epsilon + +def test_indexing(): + """Test sequence protocol indexing""" + # Use values that are exact in float32: 3.5 = 7/2, 7.5 = 15/2 + v = mcrfpy.Vector(3.5, 7.5) + + # Positive indices + assert v[0] == 3.5, f"v[0] should be 3.5, got {v[0]}" + assert v[1] == 7.5, f"v[1] should be 7.5, got {v[1]}" + + # Negative indices + assert v[-1] == 7.5, f"v[-1] should be 7.5, got {v[-1]}" + assert v[-2] == 3.5, f"v[-2] should be 3.5, got {v[-2]}" + + # Out of bounds + try: + _ = v[2] + assert False, "v[2] should raise IndexError" + except IndexError: + pass + + try: + _ = v[-3] + assert False, "v[-3] should raise IndexError" + except IndexError: + pass + + print(" [PASS] Indexing") + +def test_length(): + """Test len() on Vector""" + v = mcrfpy.Vector(1, 2) + assert len(v) == 2, f"len(Vector) should be 2, got {len(v)}" + print(" [PASS] Length") + +def test_iteration(): + """Test iteration and unpacking""" + # Use values that are exact in float32 + v = mcrfpy.Vector(10.5, 20.5) + + # Iteration - use approximate comparison for float32 precision + values = list(v) + assert len(values) == 2, f"list(v) should have 2 elements" + assert approx(values[0], 10.5), f"list(v)[0] should be ~10.5, got {values[0]}" + assert approx(values[1], 20.5), f"list(v)[1] should be ~20.5, got {values[1]}" + + # Unpacking + x, y = v + assert approx(x, 10.5), f"Unpacked x should be ~10.5, got {x}" + assert approx(y, 20.5), f"Unpacked y should be ~20.5, got {y}" + + # tuple() conversion + t = tuple(v) + assert len(t) == 2 and approx(t[0], 10.5) and approx(t[1], 20.5), f"tuple(v) should be ~(10.5, 20.5), got {t}" + + print(" [PASS] Iteration and unpacking") + +def test_tuple_comparison(): + """Test comparison with tuples""" + # Use integer values which are exact in float32 + v = mcrfpy.Vector(5, 6) + + # Vector == tuple (integers are exact) + assert v == (5, 6), "Vector(5, 6) should equal (5, 6)" + assert v == (5.0, 6.0), "Vector(5, 6) should equal (5.0, 6.0)" + + # Vector != tuple + assert v != (5, 7), "Vector(5, 6) should not equal (5, 7)" + assert v != (4, 6), "Vector(5, 6) should not equal (4, 6)" + + # Tuple == Vector (reverse comparison) + assert (5, 6) == v, "(5, 6) should equal Vector(5, 6)" + assert (5, 7) != v, "(5, 7) should not equal Vector(5, 6)" + + # Edge cases + v_zero = mcrfpy.Vector(0, 0) + assert v_zero == (0, 0), "Vector(0, 0) should equal (0, 0)" + assert v_zero == (0.0, 0.0), "Vector(0, 0) should equal (0.0, 0.0)" + + # Negative values - use exact float32 values (x.5 are exact) + v_neg = mcrfpy.Vector(-3.5, -7.5) + assert v_neg == (-3.5, -7.5), "Vector(-3.5, -7.5) should equal (-3.5, -7.5)" + + print(" [PASS] Tuple comparison") + +def test_floor_method(): + """Test .floor() method""" + # Use values that clearly floor to different integers + v = mcrfpy.Vector(3.75, -2.25) # exact in float32 + floored = v.floor() + + assert isinstance(floored, mcrfpy.Vector), ".floor() should return a Vector" + assert floored.x == 3.0, f"floor(3.75) should be 3.0, got {floored.x}" + assert floored.y == -3.0, f"floor(-2.25) should be -3.0, got {floored.y}" + + # Positive values (use exact float32 values) + v2 = mcrfpy.Vector(5.875, 0.125) # exact in float32 + f2 = v2.floor() + assert f2 == (5.0, 0.0), f"floor(5.875, 0.125) should be (5.0, 0.0), got ({f2.x}, {f2.y})" + + # Already integers + v3 = mcrfpy.Vector(10.0, 20.0) + f3 = v3.floor() + assert f3 == (10.0, 20.0), f"floor(10.0, 20.0) should be (10.0, 20.0)" + + print(" [PASS] .floor() method") + +def test_int_property(): + """Test .int property""" + # Use exact float32 values + v = mcrfpy.Vector(3.75, -2.25) + int_tuple = v.int + + assert isinstance(int_tuple, tuple), ".int should return a tuple" + assert len(int_tuple) == 2, ".int tuple should have 2 elements" + assert int_tuple == (3, -3), f".int should be (3, -3), got {int_tuple}" + + # Check it's hashable (can be used as dict key) + d = {} + d[v.int] = "test" + assert d[(3, -3)] == "test", ".int tuple should work as dict key" + + # Positive values (use exact float32 values) + v2 = mcrfpy.Vector(5.875, 0.125) + assert v2.int == (5, 0), f".int should be (5, 0), got {v2.int}" + + print(" [PASS] .int property") + +def test_bool_check(): + """Test boolean conversion (already implemented, verify it works)""" + v_zero = mcrfpy.Vector(0, 0) + v_nonzero = mcrfpy.Vector(1, 0) + v_nonzero2 = mcrfpy.Vector(0, 1) + + assert not bool(v_zero), "Vector(0, 0) should be falsey" + assert bool(v_nonzero), "Vector(1, 0) should be truthy" + assert bool(v_nonzero2), "Vector(0, 1) should be truthy" + + # In if statement + if v_zero: + assert False, "Vector(0, 0) should not pass if check" + + if not v_nonzero: + assert False, "Vector(1, 0) should pass if check" + + print(" [PASS] Boolean check") + +def test_combined_operations(): + """Test that new features work together with existing operations""" + # Use exact float32 values + v1 = mcrfpy.Vector(3.5, 4.5) + v2 = mcrfpy.Vector(1.5, 2.5) + + # Arithmetic then tuple comparison (sums are exact) + result = v1 + v2 + assert result == (5.0, 7.0), f"(3.5+1.5, 4.5+2.5) should equal (5.0, 7.0), got ({result.x}, {result.y})" + + # Floor then use as dict key + floored = v1.floor() + positions = {floored.int: "player"} + assert (3, 4) in positions, "floored.int should work as dict key" + + # Unpack, modify, compare (products are exact) + x, y = v1 + v3 = mcrfpy.Vector(x * 2, y * 2) + assert v3 == (7.0, 9.0), f"Unpacking and creating new vector should work, got ({v3.x}, {v3.y})" + + print(" [PASS] Combined operations") + +def run_tests(): + """Run all tests""" + print("Testing Vector convenience features (Issue #109)...") + + test_indexing() + test_length() + test_iteration() + test_tuple_comparison() + test_floor_method() + test_int_property() + test_bool_check() + test_combined_operations() + + print("\n[ALL TESTS PASSED]") + sys.exit(0) + +# Run tests immediately (no game loop needed) +run_tests()