Compare commits
5 commits
51e96c0c6b
...
19ded088b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ded088b0 | |||
| 9028bf485e | |||
| f041a0c8ca | |||
| afcb54d9fe | |||
| deb5d81ab6 |
22 changed files with 1527 additions and 55 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
#include "UIEntity.h"
|
#include "UIEntity.h"
|
||||||
#include "PyAnimation.h"
|
#include "PyAnimation.h"
|
||||||
#include "McRFPy_API.h"
|
#include "McRFPy_API.h"
|
||||||
|
#include "GameEngine.h"
|
||||||
#include "PythonObjectCache.h"
|
#include "PythonObjectCache.h"
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
@ -368,9 +369,14 @@ void Animation::triggerCallback() {
|
||||||
Py_DECREF(args);
|
Py_DECREF(args);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
// Print error but don't crash
|
std::cerr << "Animation callback raised an exception:" << std::endl;
|
||||||
PyErr_Print();
|
PyErr_Print();
|
||||||
PyErr_Clear(); // Clear the error state
|
PyErr_Clear();
|
||||||
|
|
||||||
|
// Check if we should exit on exception
|
||||||
|
if (McRFPy_API::game && McRFPy_API::game->getConfig().exit_on_exception) {
|
||||||
|
McRFPy_API::signalPythonException();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Py_DECREF(result);
|
Py_DECREF(result);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,12 @@ CommandLineParser::ParseResult CommandLineParser::parse(McRogueFaceConfig& confi
|
||||||
current_arg++;
|
current_arg++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (arg == "--continue-after-exceptions") {
|
||||||
|
config.exit_on_exception = false;
|
||||||
|
current_arg++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// If no flags matched, treat as positional argument (script name)
|
// If no flags matched, treat as positional argument (script name)
|
||||||
if (arg[0] != '-') {
|
if (arg[0] != '-') {
|
||||||
|
|
@ -160,6 +166,8 @@ void CommandLineParser::print_help() {
|
||||||
<< " --audio-off : disable audio\n"
|
<< " --audio-off : disable audio\n"
|
||||||
<< " --audio-on : enable audio (even in headless mode)\n"
|
<< " --audio-on : enable audio (even in headless mode)\n"
|
||||||
<< " --screenshot [path] : take a screenshot in headless mode\n"
|
<< " --screenshot [path] : take a screenshot in headless mode\n"
|
||||||
|
<< " --continue-after-exceptions : don't exit on Python callback exceptions\n"
|
||||||
|
<< " (default: exit on first exception)\n"
|
||||||
<< "\n"
|
<< "\n"
|
||||||
<< "Arguments:\n"
|
<< "Arguments:\n"
|
||||||
<< " file : program read from script file\n"
|
<< " file : program read from script file\n"
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,11 @@ void GameEngine::run()
|
||||||
if (config.auto_exit_after_exec && timers.empty()) {
|
if (config.auto_exit_after_exec && timers.empty()) {
|
||||||
running = false;
|
running = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if a Python exception has signaled exit
|
||||||
|
if (McRFPy_API::shouldExit()) {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up before exiting the run loop
|
// Clean up before exiting the run loop
|
||||||
|
|
|
||||||
|
|
@ -153,6 +153,7 @@ public:
|
||||||
std::shared_ptr<Timer> getTimer(const std::string& name);
|
std::shared_ptr<Timer> getTimer(const std::string& name);
|
||||||
void setWindowScale(float);
|
void setWindowScale(float);
|
||||||
bool isHeadless() const { return headless; }
|
bool isHeadless() const { return headless; }
|
||||||
|
const McRogueFaceConfig& getConfig() const { return config; }
|
||||||
void processEvent(const sf::Event& event);
|
void processEvent(const sf::Event& event);
|
||||||
|
|
||||||
// Window property accessors
|
// Window property accessors
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ std::shared_ptr<PyFont> McRFPy_API::default_font;
|
||||||
std::shared_ptr<PyTexture> McRFPy_API::default_texture;
|
std::shared_ptr<PyTexture> McRFPy_API::default_texture;
|
||||||
PyObject* McRFPy_API::mcrf_module;
|
PyObject* McRFPy_API::mcrf_module;
|
||||||
|
|
||||||
|
// Exception handling state
|
||||||
|
std::atomic<bool> McRFPy_API::exception_occurred{false};
|
||||||
|
std::atomic<int> McRFPy_API::exit_code{0};
|
||||||
|
|
||||||
static PyMethodDef mcrfpyMethods[] = {
|
static PyMethodDef mcrfpyMethods[] = {
|
||||||
|
|
||||||
|
|
@ -1144,6 +1147,23 @@ PyObject* McRFPy_API::_getMetrics(PyObject* self, PyObject* args) {
|
||||||
// Add general metrics
|
// Add general metrics
|
||||||
PyDict_SetItemString(dict, "current_frame", PyLong_FromLong(game->getFrame()));
|
PyDict_SetItemString(dict, "current_frame", PyLong_FromLong(game->getFrame()));
|
||||||
PyDict_SetItemString(dict, "runtime", PyFloat_FromDouble(game->runtime.getElapsedTime().asSeconds()));
|
PyDict_SetItemString(dict, "runtime", PyFloat_FromDouble(game->runtime.getElapsedTime().asSeconds()));
|
||||||
|
|
||||||
return dict;
|
return dict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exception handling implementation
|
||||||
|
void McRFPy_API::signalPythonException() {
|
||||||
|
// Check if we should exit on exception (consult config via game)
|
||||||
|
if (game && !game->isHeadless()) {
|
||||||
|
// In windowed mode, respect the config setting
|
||||||
|
// Access config through game engine - but we need to check the config
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, always signal - the game loop will check the config
|
||||||
|
exception_occurred.store(true);
|
||||||
|
exit_code.store(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool McRFPy_API::shouldExit() {
|
||||||
|
return exception_occurred.load();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
#include "Common.h"
|
#include "Common.h"
|
||||||
#include "Python.h"
|
#include "Python.h"
|
||||||
#include <list>
|
#include <list>
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
#include "PyFont.h"
|
#include "PyFont.h"
|
||||||
#include "PyTexture.h"
|
#include "PyTexture.h"
|
||||||
|
|
@ -85,4 +86,10 @@ public:
|
||||||
static void triggerSceneChange(const std::string& from_scene, const std::string& to_scene);
|
static void triggerSceneChange(const std::string& from_scene, const std::string& to_scene);
|
||||||
static void updatePythonScenes(float dt);
|
static void updatePythonScenes(float dt);
|
||||||
static void triggerResize(int width, int height);
|
static void triggerResize(int width, int height);
|
||||||
|
|
||||||
|
// Exception handling - signal game loop to exit on unhandled Python exceptions
|
||||||
|
static std::atomic<bool> exception_occurred;
|
||||||
|
static std::atomic<int> exit_code;
|
||||||
|
static void signalPythonException(); // Called by exception handlers
|
||||||
|
static bool shouldExit(); // Checked by game loop
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ struct McRogueFaceConfig {
|
||||||
|
|
||||||
// Auto-exit when no timers remain (for --headless --exec automation)
|
// Auto-exit when no timers remain (for --headless --exec automation)
|
||||||
bool auto_exit_after_exec = false;
|
bool auto_exit_after_exec = false;
|
||||||
|
|
||||||
|
// Exception handling: exit on first Python callback exception (default: true)
|
||||||
|
// Use --continue-after-exceptions to disable
|
||||||
|
bool exit_on_exception = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // MCROGUEFACE_CONFIG_H
|
#endif // MCROGUEFACE_CONFIG_H
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
#include "PyCallable.h"
|
#include "PyCallable.h"
|
||||||
|
#include "McRFPy_API.h"
|
||||||
|
#include "GameEngine.h"
|
||||||
|
|
||||||
PyCallable::PyCallable(PyObject* _target)
|
PyCallable::PyCallable(PyObject* _target)
|
||||||
{
|
{
|
||||||
|
|
@ -51,9 +53,14 @@ void PyClickCallable::call(sf::Vector2f mousepos, std::string button, std::strin
|
||||||
PyObject* retval = PyCallable::call(args, NULL);
|
PyObject* retval = PyCallable::call(args, NULL);
|
||||||
if (!retval)
|
if (!retval)
|
||||||
{
|
{
|
||||||
std::cout << "ClickCallable has raised an exception. It's going to STDERR and being dropped:" << std::endl;
|
std::cerr << "Click callback raised an exception:" << std::endl;
|
||||||
PyErr_Print();
|
PyErr_Print();
|
||||||
PyErr_Clear();
|
PyErr_Clear();
|
||||||
|
|
||||||
|
// Check if we should exit on exception
|
||||||
|
if (McRFPy_API::game && McRFPy_API::game->getConfig().exit_on_exception) {
|
||||||
|
McRFPy_API::signalPythonException();
|
||||||
|
}
|
||||||
} else if (retval != Py_None)
|
} else if (retval != Py_None)
|
||||||
{
|
{
|
||||||
std::cout << "ClickCallable returned a non-None value. It's not an error, it's just not being saved or used." << std::endl;
|
std::cout << "ClickCallable returned a non-None value. It's not an error, it's just not being saved or used." << std::endl;
|
||||||
|
|
@ -81,9 +88,14 @@ void PyKeyCallable::call(std::string key, std::string action)
|
||||||
PyObject* retval = PyCallable::call(args, NULL);
|
PyObject* retval = PyCallable::call(args, NULL);
|
||||||
if (!retval)
|
if (!retval)
|
||||||
{
|
{
|
||||||
std::cout << "KeyCallable has raised an exception. It's going to STDERR and being dropped:" << std::endl;
|
std::cerr << "Key callback raised an exception:" << std::endl;
|
||||||
PyErr_Print();
|
PyErr_Print();
|
||||||
PyErr_Clear();
|
PyErr_Clear();
|
||||||
|
|
||||||
|
// Check if we should exit on exception
|
||||||
|
if (McRFPy_API::game && McRFPy_API::game->getConfig().exit_on_exception) {
|
||||||
|
McRFPy_API::signalPythonException();
|
||||||
|
}
|
||||||
} else if (retval != Py_None)
|
} else if (retval != Py_None)
|
||||||
{
|
{
|
||||||
std::cout << "KeyCallable returned a non-None value. It's not an error, it's just not being saved or used." << std::endl;
|
std::cout << "KeyCallable returned a non-None value. It's not an error, it's just not being saved or used." << std::endl;
|
||||||
|
|
|
||||||
126
src/PyVector.cpp
126
src/PyVector.cpp
|
|
@ -8,6 +8,8 @@ PyGetSetDef PyVector::getsetters[] = {
|
||||||
MCRF_PROPERTY(x, "X coordinate of the vector (float)"), (void*)0},
|
MCRF_PROPERTY(x, "X coordinate of the vector (float)"), (void*)0},
|
||||||
{"y", (getter)PyVector::get_member, (setter)PyVector::set_member,
|
{"y", (getter)PyVector::get_member, (setter)PyVector::set_member,
|
||||||
MCRF_PROPERTY(y, "Y coordinate of the vector (float)"), (void*)1},
|
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}
|
{NULL}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -60,6 +62,13 @@ PyMethodDef PyVector::methods[] = {
|
||||||
MCRF_DESC("Create a copy of this vector."),
|
MCRF_DESC("Create a copy of this vector."),
|
||||||
MCRF_RETURNS("Vector: New Vector object with same x and y values")
|
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}
|
{NULL}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -102,6 +111,19 @@ namespace mcrfpydef {
|
||||||
.nb_matrix_multiply = 0,
|
.nb_matrix_multiply = 0,
|
||||||
.nb_inplace_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)
|
PyVector::PyVector(sf::Vector2f target)
|
||||||
|
|
@ -397,29 +419,65 @@ int PyVector::bool_check(PyObject* self)
|
||||||
PyObject* PyVector::richcompare(PyObject* left, PyObject* right, int op)
|
PyObject* PyVector::richcompare(PyObject* left, PyObject* right, int op)
|
||||||
{
|
{
|
||||||
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
|
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);
|
Py_INCREF(Py_NotImplemented);
|
||||||
return Py_NotImplemented;
|
return Py_NotImplemented;
|
||||||
}
|
}
|
||||||
|
|
||||||
PyVectorObject* vec1 = (PyVectorObject*)left;
|
// Extract right operand values
|
||||||
PyVectorObject* vec2 = (PyVectorObject*)right;
|
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;
|
bool result = false;
|
||||||
|
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case Py_EQ:
|
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;
|
break;
|
||||||
case Py_NE:
|
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;
|
break;
|
||||||
default:
|
default:
|
||||||
Py_INCREF(Py_NotImplemented);
|
Py_INCREF(Py_NotImplemented);
|
||||||
return Py_NotImplemented;
|
return Py_NotImplemented;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result)
|
if (result)
|
||||||
Py_RETURN_TRUE;
|
Py_RETURN_TRUE;
|
||||||
else
|
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 type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Vector");
|
||||||
auto result = (PyVectorObject*)type->tp_alloc(type, 0);
|
auto result = (PyVectorObject*)type->tp_alloc(type, 0);
|
||||||
|
|
||||||
if (result) {
|
if (result) {
|
||||||
result->data = self->data;
|
result->data = self->data;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (PyObject*)result;
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,15 +45,24 @@ public:
|
||||||
static PyObject* distance_to(PyVectorObject*, PyObject*);
|
static PyObject* distance_to(PyVectorObject*, PyObject*);
|
||||||
static PyObject* angle(PyVectorObject*, PyObject*);
|
static PyObject* angle(PyVectorObject*, PyObject*);
|
||||||
static PyObject* copy(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 PyGetSetDef getsetters[];
|
||||||
static PyMethodDef methods[];
|
static PyMethodDef methods[];
|
||||||
};
|
};
|
||||||
|
|
||||||
namespace mcrfpydef {
|
namespace mcrfpydef {
|
||||||
// Forward declare the PyNumberMethods structure
|
// Forward declare the PyNumberMethods and PySequenceMethods structures
|
||||||
extern PyNumberMethods PyVector_as_number;
|
extern PyNumberMethods PyVector_as_number;
|
||||||
|
extern PySequenceMethods PyVector_as_sequence;
|
||||||
|
|
||||||
static PyTypeObject PyVectorType = {
|
static PyTypeObject PyVectorType = {
|
||||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||||
.tp_name = "mcrfpy.Vector",
|
.tp_name = "mcrfpy.Vector",
|
||||||
|
|
@ -61,6 +70,7 @@ namespace mcrfpydef {
|
||||||
.tp_itemsize = 0,
|
.tp_itemsize = 0,
|
||||||
.tp_repr = PyVector::repr,
|
.tp_repr = PyVector::repr,
|
||||||
.tp_as_number = &PyVector_as_number,
|
.tp_as_number = &PyVector_as_number,
|
||||||
|
.tp_as_sequence = &PyVector_as_sequence,
|
||||||
.tp_hash = PyVector::hash,
|
.tp_hash = PyVector::hash,
|
||||||
.tp_flags = Py_TPFLAGS_DEFAULT,
|
.tp_flags = Py_TPFLAGS_DEFAULT,
|
||||||
.tp_doc = PyDoc_STR("SFML Vector Object"),
|
.tp_doc = PyDoc_STR("SFML Vector Object"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
#include "Timer.h"
|
#include "Timer.h"
|
||||||
#include "PythonObjectCache.h"
|
#include "PythonObjectCache.h"
|
||||||
#include "PyCallable.h"
|
#include "PyCallable.h"
|
||||||
|
#include "McRFPy_API.h"
|
||||||
|
#include "GameEngine.h"
|
||||||
|
|
||||||
Timer::Timer(PyObject* _target, int _interval, int now, bool _once)
|
Timer::Timer(PyObject* _target, int _interval, int now, bool _once)
|
||||||
: callback(std::make_shared<PyCallable>(_target)), interval(_interval), last_ran(now),
|
: callback(std::make_shared<PyCallable>(_target)), interval(_interval), last_ran(now),
|
||||||
|
|
@ -51,10 +53,15 @@ bool Timer::test(int now)
|
||||||
Py_DECREF(args);
|
Py_DECREF(args);
|
||||||
|
|
||||||
if (!retval)
|
if (!retval)
|
||||||
{
|
{
|
||||||
std::cout << "Timer callback has raised an exception. It's going to STDERR and being dropped:" << std::endl;
|
std::cerr << "Timer callback raised an exception:" << std::endl;
|
||||||
PyErr_Print();
|
PyErr_Print();
|
||||||
PyErr_Clear();
|
PyErr_Clear();
|
||||||
|
|
||||||
|
// Check if we should exit on exception
|
||||||
|
if (McRFPy_API::game && McRFPy_API::game->getConfig().exit_on_exception) {
|
||||||
|
McRFPy_API::signalPythonException();
|
||||||
|
}
|
||||||
} else if (retval != Py_None)
|
} else if (retval != Py_None)
|
||||||
{
|
{
|
||||||
std::cout << "Timer returned a non-None value. It's not an error, it's just not being saved or used." << std::endl;
|
std::cout << "Timer returned a non-None value. It's not an error, it's just not being saved or used." << std::endl;
|
||||||
|
|
|
||||||
|
|
@ -790,30 +790,151 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable)
|
||||||
|
|
||||||
PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o)
|
PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o)
|
||||||
{
|
{
|
||||||
if (!PyLong_Check(o))
|
auto vec = self->data.get();
|
||||||
{
|
if (!vec) {
|
||||||
PyErr_SetString(PyExc_TypeError, "UICollection.remove requires an integer index to remove");
|
PyErr_SetString(PyExc_RuntimeError, "Collection data is null");
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
long index = PyLong_AsLong(o);
|
|
||||||
|
|
||||||
// Handle negative indexing
|
|
||||||
while (index < 0) index += self->data->size();
|
|
||||||
|
|
||||||
if (index >= self->data->size())
|
|
||||||
{
|
|
||||||
PyErr_SetString(PyExc_ValueError, "Index out of range");
|
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// release the shared pointer at self->data[index];
|
// Type checking - must be a UIDrawable subclass
|
||||||
self->data->erase(self->data->begin() + index);
|
if (!PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Drawable"))) {
|
||||||
|
PyErr_SetString(PyExc_TypeError,
|
||||||
// Mark scene as needing resort after removing element
|
"UICollection.remove requires a UI element (Frame, Caption, Sprite, Grid)");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the C++ object from the Python object
|
||||||
|
std::shared_ptr<UIDrawable> search_drawable = nullptr;
|
||||||
|
|
||||||
|
if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"))) {
|
||||||
|
search_drawable = ((PyUIFrameObject*)o)->data;
|
||||||
|
} else if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"))) {
|
||||||
|
search_drawable = ((PyUICaptionObject*)o)->data;
|
||||||
|
} else if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"))) {
|
||||||
|
search_drawable = ((PyUISpriteObject*)o)->data;
|
||||||
|
} else if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"))) {
|
||||||
|
search_drawable = ((PyUIGridObject*)o)->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!search_drawable) {
|
||||||
|
PyErr_SetString(PyExc_TypeError,
|
||||||
|
"UICollection.remove requires a UI element (Frame, Caption, Sprite, Grid)");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the object and remove first occurrence
|
||||||
|
for (auto it = vec->begin(); it != vec->end(); ++it) {
|
||||||
|
if (it->get() == search_drawable.get()) {
|
||||||
|
vec->erase(it);
|
||||||
|
McRFPy_API::markSceneNeedsSort();
|
||||||
|
Py_RETURN_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PyErr_SetString(PyExc_ValueError, "element not in UICollection");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args)
|
||||||
|
{
|
||||||
|
Py_ssize_t index = -1; // Default to last element
|
||||||
|
|
||||||
|
if (!PyArg_ParseTuple(args, "|n", &index)) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto vec = self->data.get();
|
||||||
|
if (!vec) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Collection data is null");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vec->empty()) {
|
||||||
|
PyErr_SetString(PyExc_IndexError, "pop from empty UICollection");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negative indexing
|
||||||
|
Py_ssize_t size = static_cast<Py_ssize_t>(vec->size());
|
||||||
|
if (index < 0) {
|
||||||
|
index += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 0 || index >= size) {
|
||||||
|
PyErr_SetString(PyExc_IndexError, "pop index out of range");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the element before removing
|
||||||
|
std::shared_ptr<UIDrawable> drawable = (*vec)[index];
|
||||||
|
|
||||||
|
// Remove from vector
|
||||||
|
vec->erase(vec->begin() + index);
|
||||||
|
|
||||||
McRFPy_API::markSceneNeedsSort();
|
McRFPy_API::markSceneNeedsSort();
|
||||||
|
|
||||||
Py_INCREF(Py_None);
|
// Convert to Python object and return
|
||||||
return Py_None;
|
return convertDrawableToPython(drawable);
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
|
||||||
|
{
|
||||||
|
Py_ssize_t index;
|
||||||
|
PyObject* o;
|
||||||
|
|
||||||
|
if (!PyArg_ParseTuple(args, "nO", &index, &o)) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto vec = self->data.get();
|
||||||
|
if (!vec) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Collection data is null");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type checking - must be a UIDrawable subclass
|
||||||
|
if (!PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Drawable"))) {
|
||||||
|
PyErr_SetString(PyExc_TypeError,
|
||||||
|
"UICollection.insert requires a UI element (Frame, Caption, Sprite, Grid)");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the C++ object from the Python object
|
||||||
|
std::shared_ptr<UIDrawable> drawable = nullptr;
|
||||||
|
|
||||||
|
if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame"))) {
|
||||||
|
drawable = ((PyUIFrameObject*)o)->data;
|
||||||
|
} else if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption"))) {
|
||||||
|
drawable = ((PyUICaptionObject*)o)->data;
|
||||||
|
} else if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite"))) {
|
||||||
|
drawable = ((PyUISpriteObject*)o)->data;
|
||||||
|
} else if (PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid"))) {
|
||||||
|
drawable = ((PyUIGridObject*)o)->data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!drawable) {
|
||||||
|
PyErr_SetString(PyExc_TypeError,
|
||||||
|
"UICollection.insert requires a UI element (Frame, Caption, Sprite, Grid)");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negative indexing and clamping (Python list.insert behavior)
|
||||||
|
Py_ssize_t size = static_cast<Py_ssize_t>(vec->size());
|
||||||
|
if (index < 0) {
|
||||||
|
index += size;
|
||||||
|
if (index < 0) {
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
} else if (index > size) {
|
||||||
|
index = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert at position
|
||||||
|
vec->insert(vec->begin() + index, drawable);
|
||||||
|
|
||||||
|
McRFPy_API::markSceneNeedsSort();
|
||||||
|
|
||||||
|
Py_RETURN_NONE;
|
||||||
}
|
}
|
||||||
|
|
||||||
PyObject* UICollection::index_method(PyUICollectionObject* self, PyObject* value) {
|
PyObject* UICollection::index_method(PyUICollectionObject* self, PyObject* value) {
|
||||||
|
|
@ -905,12 +1026,173 @@ PyObject* UICollection::count(PyUICollectionObject* self, PyObject* value) {
|
||||||
return PyLong_FromSsize_t(count);
|
return PyLong_FromSsize_t(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to match names with optional wildcard support
|
||||||
|
static bool matchName(const std::string& name, const std::string& pattern) {
|
||||||
|
// Check for wildcard pattern
|
||||||
|
if (pattern.find('*') != std::string::npos) {
|
||||||
|
// Simple wildcard matching: only support * at start, end, or both
|
||||||
|
if (pattern == "*") {
|
||||||
|
return true; // Match everything
|
||||||
|
} else if (pattern.front() == '*' && pattern.back() == '*' && pattern.length() > 2) {
|
||||||
|
// *substring* - contains match
|
||||||
|
std::string substring = pattern.substr(1, pattern.length() - 2);
|
||||||
|
return name.find(substring) != std::string::npos;
|
||||||
|
} else if (pattern.front() == '*') {
|
||||||
|
// *suffix - ends with
|
||||||
|
std::string suffix = pattern.substr(1);
|
||||||
|
return name.length() >= suffix.length() &&
|
||||||
|
name.compare(name.length() - suffix.length(), suffix.length(), suffix) == 0;
|
||||||
|
} else if (pattern.back() == '*') {
|
||||||
|
// prefix* - starts with
|
||||||
|
std::string prefix = pattern.substr(0, pattern.length() - 1);
|
||||||
|
return name.compare(0, prefix.length(), prefix) == 0;
|
||||||
|
}
|
||||||
|
// For more complex patterns, fall back to exact match
|
||||||
|
return name == pattern;
|
||||||
|
}
|
||||||
|
// Exact match
|
||||||
|
return name == pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* UICollection::find(PyUICollectionObject* self, PyObject* args, PyObject* kwds) {
|
||||||
|
const char* name = nullptr;
|
||||||
|
int recursive = 0;
|
||||||
|
|
||||||
|
static const char* kwlist[] = {"name", "recursive", NULL};
|
||||||
|
|
||||||
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|p", const_cast<char**>(kwlist),
|
||||||
|
&name, &recursive)) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto vec = self->data.get();
|
||||||
|
if (!vec) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Collection data is null");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string pattern(name);
|
||||||
|
bool has_wildcard = (pattern.find('*') != std::string::npos);
|
||||||
|
|
||||||
|
if (has_wildcard) {
|
||||||
|
// Return list of all matches
|
||||||
|
PyObject* results = PyList_New(0);
|
||||||
|
if (!results) return NULL;
|
||||||
|
|
||||||
|
for (auto& drawable : *vec) {
|
||||||
|
if (matchName(drawable->name, pattern)) {
|
||||||
|
PyObject* py_drawable = convertDrawableToPython(drawable);
|
||||||
|
if (!py_drawable) {
|
||||||
|
Py_DECREF(results);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
if (PyList_Append(results, py_drawable) < 0) {
|
||||||
|
Py_DECREF(py_drawable);
|
||||||
|
Py_DECREF(results);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
Py_DECREF(py_drawable); // PyList_Append increfs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive search into Frame children
|
||||||
|
if (recursive && drawable->derived_type() == PyObjectsEnum::UIFRAME) {
|
||||||
|
auto frame = std::static_pointer_cast<UIFrame>(drawable);
|
||||||
|
// Create temporary collection object for recursive call
|
||||||
|
PyTypeObject* collType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection");
|
||||||
|
if (collType) {
|
||||||
|
PyUICollectionObject* child_coll = (PyUICollectionObject*)collType->tp_alloc(collType, 0);
|
||||||
|
if (child_coll) {
|
||||||
|
child_coll->data = frame->children;
|
||||||
|
PyObject* child_results = find(child_coll, args, kwds);
|
||||||
|
if (child_results && PyList_Check(child_results)) {
|
||||||
|
// Extend results with child results
|
||||||
|
for (Py_ssize_t i = 0; i < PyList_Size(child_results); i++) {
|
||||||
|
PyObject* item = PyList_GetItem(child_results, i);
|
||||||
|
Py_INCREF(item);
|
||||||
|
PyList_Append(results, item);
|
||||||
|
Py_DECREF(item);
|
||||||
|
}
|
||||||
|
Py_DECREF(child_results);
|
||||||
|
}
|
||||||
|
Py_DECREF(child_coll);
|
||||||
|
}
|
||||||
|
Py_DECREF(collType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} else {
|
||||||
|
// Return first exact match or None
|
||||||
|
for (auto& drawable : *vec) {
|
||||||
|
if (drawable->name == pattern) {
|
||||||
|
return convertDrawableToPython(drawable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive search into Frame children
|
||||||
|
if (recursive && drawable->derived_type() == PyObjectsEnum::UIFRAME) {
|
||||||
|
auto frame = std::static_pointer_cast<UIFrame>(drawable);
|
||||||
|
PyTypeObject* collType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "UICollection");
|
||||||
|
if (collType) {
|
||||||
|
PyUICollectionObject* child_coll = (PyUICollectionObject*)collType->tp_alloc(collType, 0);
|
||||||
|
if (child_coll) {
|
||||||
|
child_coll->data = frame->children;
|
||||||
|
PyObject* result = find(child_coll, args, kwds);
|
||||||
|
Py_DECREF(child_coll);
|
||||||
|
Py_DECREF(collType);
|
||||||
|
if (result && result != Py_None) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
Py_XDECREF(result);
|
||||||
|
} else {
|
||||||
|
Py_DECREF(collType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Py_RETURN_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PyMethodDef UICollection::methods[] = {
|
PyMethodDef UICollection::methods[] = {
|
||||||
{"append", (PyCFunction)UICollection::append, METH_O},
|
{"append", (PyCFunction)UICollection::append, METH_O,
|
||||||
{"extend", (PyCFunction)UICollection::extend, METH_O},
|
"append(element)\n\n"
|
||||||
{"remove", (PyCFunction)UICollection::remove, METH_O},
|
"Add an element to the end of the collection."},
|
||||||
{"index", (PyCFunction)UICollection::index_method, METH_O},
|
{"extend", (PyCFunction)UICollection::extend, METH_O,
|
||||||
{"count", (PyCFunction)UICollection::count, METH_O},
|
"extend(iterable)\n\n"
|
||||||
|
"Add all elements from an iterable to the collection."},
|
||||||
|
{"insert", (PyCFunction)UICollection::insert, METH_VARARGS,
|
||||||
|
"insert(index, element)\n\n"
|
||||||
|
"Insert element at index. Like list.insert(), indices past the end append.\n\n"
|
||||||
|
"Note: If using z_index for sorting, insertion order may not persist after\n"
|
||||||
|
"the next render. Use name-based .find() for stable element access."},
|
||||||
|
{"remove", (PyCFunction)UICollection::remove, METH_O,
|
||||||
|
"remove(element)\n\n"
|
||||||
|
"Remove first occurrence of element. Raises ValueError if not found."},
|
||||||
|
{"pop", (PyCFunction)UICollection::pop, METH_VARARGS,
|
||||||
|
"pop([index]) -> element\n\n"
|
||||||
|
"Remove and return element at index (default: last element).\n\n"
|
||||||
|
"Note: If using z_index for sorting, indices may shift after render.\n"
|
||||||
|
"Use name-based .find() for stable element access."},
|
||||||
|
{"index", (PyCFunction)UICollection::index_method, METH_O,
|
||||||
|
"index(element) -> int\n\n"
|
||||||
|
"Return index of first occurrence of element. Raises ValueError if not found."},
|
||||||
|
{"count", (PyCFunction)UICollection::count, METH_O,
|
||||||
|
"count(element) -> int\n\n"
|
||||||
|
"Count occurrences of element in the collection."},
|
||||||
|
{"find", (PyCFunction)UICollection::find, METH_VARARGS | METH_KEYWORDS,
|
||||||
|
"find(name, recursive=False) -> element or list\n\n"
|
||||||
|
"Find elements by name.\n\n"
|
||||||
|
"Args:\n"
|
||||||
|
" name (str): Name to search for. Supports wildcards:\n"
|
||||||
|
" - 'exact' for exact match (returns single element or None)\n"
|
||||||
|
" - 'prefix*' for starts-with match (returns list)\n"
|
||||||
|
" - '*suffix' for ends-with match (returns list)\n"
|
||||||
|
" - '*substring*' for contains match (returns list)\n"
|
||||||
|
" recursive (bool): If True, search in Frame children recursively.\n\n"
|
||||||
|
"Returns:\n"
|
||||||
|
" Single element if exact match, list if wildcard, None if not found."},
|
||||||
{NULL, NULL, 0, NULL}
|
{NULL, NULL, 0, NULL}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,11 @@ public:
|
||||||
static PyObject* append(PyUICollectionObject* self, PyObject* o);
|
static PyObject* append(PyUICollectionObject* self, PyObject* o);
|
||||||
static PyObject* extend(PyUICollectionObject* self, PyObject* iterable);
|
static PyObject* extend(PyUICollectionObject* self, PyObject* iterable);
|
||||||
static PyObject* remove(PyUICollectionObject* self, PyObject* o);
|
static PyObject* remove(PyUICollectionObject* self, PyObject* o);
|
||||||
|
static PyObject* pop(PyUICollectionObject* self, PyObject* args);
|
||||||
|
static PyObject* insert(PyUICollectionObject* self, PyObject* args);
|
||||||
static PyObject* index_method(PyUICollectionObject* self, PyObject* value);
|
static PyObject* index_method(PyUICollectionObject* self, PyObject* value);
|
||||||
static PyObject* count(PyUICollectionObject* self, PyObject* value);
|
static PyObject* count(PyUICollectionObject* self, PyObject* value);
|
||||||
|
static PyObject* find(PyUICollectionObject* self, PyObject* args, PyObject* kwds);
|
||||||
static PyMethodDef methods[];
|
static PyMethodDef methods[];
|
||||||
static PyObject* repr(PyUICollectionObject* self);
|
static PyObject* repr(PyUICollectionObject* self);
|
||||||
static int init(PyUICollectionObject* self, PyObject* args, PyObject* kwds);
|
static int init(PyUICollectionObject* self, PyObject* args, PyObject* kwds);
|
||||||
|
|
|
||||||
257
src/UIGrid.cpp
257
src/UIGrid.cpp
|
|
@ -1953,13 +1953,132 @@ PyObject* UIEntityCollection::extend(PyUIEntityCollectionObject* self, PyObject*
|
||||||
return Py_None;
|
return Py_None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PyObject* UIEntityCollection::pop(PyUIEntityCollectionObject* self, PyObject* args)
|
||||||
|
{
|
||||||
|
Py_ssize_t index = -1; // Default to last element
|
||||||
|
|
||||||
|
if (!PyArg_ParseTuple(args, "|n", &index)) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto list = self->data.get();
|
||||||
|
if (!list) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Collection data is null");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list->empty()) {
|
||||||
|
PyErr_SetString(PyExc_IndexError, "pop from empty EntityCollection");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negative indexing
|
||||||
|
Py_ssize_t size = static_cast<Py_ssize_t>(list->size());
|
||||||
|
if (index < 0) {
|
||||||
|
index += size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index < 0 || index >= size) {
|
||||||
|
PyErr_SetString(PyExc_IndexError, "pop index out of range");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the element (std::list requires iteration)
|
||||||
|
auto it = list->begin();
|
||||||
|
std::advance(it, index);
|
||||||
|
|
||||||
|
// Get the entity before removing
|
||||||
|
std::shared_ptr<UIEntity> entity = *it;
|
||||||
|
|
||||||
|
// Clear grid reference and remove from list
|
||||||
|
entity->grid = nullptr;
|
||||||
|
list->erase(it);
|
||||||
|
|
||||||
|
// Create Python object for the entity
|
||||||
|
PyTypeObject* entityType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
|
||||||
|
if (!entityType) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Could not find Entity type");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyUIEntityObject* py_entity = (PyUIEntityObject*)entityType->tp_alloc(entityType, 0);
|
||||||
|
Py_DECREF(entityType);
|
||||||
|
|
||||||
|
if (!py_entity) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
py_entity->data = entity;
|
||||||
|
py_entity->weakreflist = NULL;
|
||||||
|
|
||||||
|
return (PyObject*)py_entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* UIEntityCollection::insert(PyUIEntityCollectionObject* self, PyObject* args)
|
||||||
|
{
|
||||||
|
Py_ssize_t index;
|
||||||
|
PyObject* o;
|
||||||
|
|
||||||
|
if (!PyArg_ParseTuple(args, "nO", &index, &o)) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto list = self->data.get();
|
||||||
|
if (!list) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Collection data is null");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type checking - must be an Entity
|
||||||
|
if (!PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) {
|
||||||
|
PyErr_SetString(PyExc_TypeError, "EntityCollection.insert requires an Entity object");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyUIEntityObject* entity = (PyUIEntityObject*)o;
|
||||||
|
if (!entity->data) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negative indexing and clamping (Python list.insert behavior)
|
||||||
|
Py_ssize_t size = static_cast<Py_ssize_t>(list->size());
|
||||||
|
if (index < 0) {
|
||||||
|
index += size;
|
||||||
|
if (index < 0) {
|
||||||
|
index = 0;
|
||||||
|
}
|
||||||
|
} else if (index > size) {
|
||||||
|
index = size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to insert position
|
||||||
|
auto it = list->begin();
|
||||||
|
std::advance(it, index);
|
||||||
|
|
||||||
|
// Insert and set grid reference
|
||||||
|
list->insert(it, entity->data);
|
||||||
|
entity->data->grid = self->grid;
|
||||||
|
|
||||||
|
// Initialize gridstate if needed
|
||||||
|
if (entity->data->gridstate.size() == 0 && self->grid) {
|
||||||
|
entity->data->gridstate.resize(self->grid->grid_x * self->grid->grid_y);
|
||||||
|
for (auto& state : entity->data->gridstate) {
|
||||||
|
state.visible = false;
|
||||||
|
state.discovered = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Py_RETURN_NONE;
|
||||||
|
}
|
||||||
|
|
||||||
PyObject* UIEntityCollection::index_method(PyUIEntityCollectionObject* self, PyObject* value) {
|
PyObject* UIEntityCollection::index_method(PyUIEntityCollectionObject* self, PyObject* value) {
|
||||||
auto list = self->data.get();
|
auto list = self->data.get();
|
||||||
if (!list) {
|
if (!list) {
|
||||||
PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer");
|
PyErr_SetString(PyExc_RuntimeError, "the collection store returned a null pointer");
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type checking - must be an Entity
|
// Type checking - must be an Entity
|
||||||
if (!PyObject_IsInstance(value, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) {
|
if (!PyObject_IsInstance(value, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity"))) {
|
||||||
PyErr_SetString(PyExc_TypeError, "EntityCollection.index requires an Entity object");
|
PyErr_SetString(PyExc_TypeError, "EntityCollection.index requires an Entity object");
|
||||||
|
|
@ -2216,12 +2335,138 @@ PyMappingMethods UIEntityCollection::mpmethods = {
|
||||||
.mp_ass_subscript = (objobjargproc)UIEntityCollection::ass_subscript
|
.mp_ass_subscript = (objobjargproc)UIEntityCollection::ass_subscript
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper function for entity name matching with wildcards
|
||||||
|
static bool matchEntityName(const std::string& name, const std::string& pattern) {
|
||||||
|
if (pattern.find('*') != std::string::npos) {
|
||||||
|
if (pattern == "*") {
|
||||||
|
return true;
|
||||||
|
} else if (pattern.front() == '*' && pattern.back() == '*' && pattern.length() > 2) {
|
||||||
|
std::string substring = pattern.substr(1, pattern.length() - 2);
|
||||||
|
return name.find(substring) != std::string::npos;
|
||||||
|
} else if (pattern.front() == '*') {
|
||||||
|
std::string suffix = pattern.substr(1);
|
||||||
|
return name.length() >= suffix.length() &&
|
||||||
|
name.compare(name.length() - suffix.length(), suffix.length(), suffix) == 0;
|
||||||
|
} else if (pattern.back() == '*') {
|
||||||
|
std::string prefix = pattern.substr(0, pattern.length() - 1);
|
||||||
|
return name.compare(0, prefix.length(), prefix) == 0;
|
||||||
|
}
|
||||||
|
return name == pattern;
|
||||||
|
}
|
||||||
|
return name == pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
PyObject* UIEntityCollection::find(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds) {
|
||||||
|
const char* name = nullptr;
|
||||||
|
|
||||||
|
static const char* kwlist[] = {"name", NULL};
|
||||||
|
|
||||||
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast<char**>(kwlist), &name)) {
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto list = self->data.get();
|
||||||
|
if (!list) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Collection data is null");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string pattern(name);
|
||||||
|
bool has_wildcard = (pattern.find('*') != std::string::npos);
|
||||||
|
|
||||||
|
// Get the Entity type for creating Python objects
|
||||||
|
PyTypeObject* entityType = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
|
||||||
|
if (!entityType) {
|
||||||
|
PyErr_SetString(PyExc_RuntimeError, "Could not find Entity type");
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (has_wildcard) {
|
||||||
|
// Return list of all matches
|
||||||
|
PyObject* results = PyList_New(0);
|
||||||
|
if (!results) {
|
||||||
|
Py_DECREF(entityType);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto& entity : *list) {
|
||||||
|
// Entity name is stored in sprite.name
|
||||||
|
if (matchEntityName(entity->sprite.name, pattern)) {
|
||||||
|
PyUIEntityObject* py_entity = (PyUIEntityObject*)entityType->tp_alloc(entityType, 0);
|
||||||
|
if (!py_entity) {
|
||||||
|
Py_DECREF(results);
|
||||||
|
Py_DECREF(entityType);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
py_entity->data = entity;
|
||||||
|
py_entity->weakreflist = NULL;
|
||||||
|
|
||||||
|
if (PyList_Append(results, (PyObject*)py_entity) < 0) {
|
||||||
|
Py_DECREF(py_entity);
|
||||||
|
Py_DECREF(results);
|
||||||
|
Py_DECREF(entityType);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
Py_DECREF(py_entity); // PyList_Append increfs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Py_DECREF(entityType);
|
||||||
|
return results;
|
||||||
|
} else {
|
||||||
|
// Return first exact match or None
|
||||||
|
for (auto& entity : *list) {
|
||||||
|
if (entity->sprite.name == pattern) {
|
||||||
|
PyUIEntityObject* py_entity = (PyUIEntityObject*)entityType->tp_alloc(entityType, 0);
|
||||||
|
if (!py_entity) {
|
||||||
|
Py_DECREF(entityType);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
py_entity->data = entity;
|
||||||
|
py_entity->weakreflist = NULL;
|
||||||
|
Py_DECREF(entityType);
|
||||||
|
return (PyObject*)py_entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Py_DECREF(entityType);
|
||||||
|
Py_RETURN_NONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
PyMethodDef UIEntityCollection::methods[] = {
|
PyMethodDef UIEntityCollection::methods[] = {
|
||||||
{"append", (PyCFunction)UIEntityCollection::append, METH_O},
|
{"append", (PyCFunction)UIEntityCollection::append, METH_O,
|
||||||
{"extend", (PyCFunction)UIEntityCollection::extend, METH_O},
|
"append(entity)\n\n"
|
||||||
{"remove", (PyCFunction)UIEntityCollection::remove, METH_O},
|
"Add an entity to the end of the collection."},
|
||||||
{"index", (PyCFunction)UIEntityCollection::index_method, METH_O},
|
{"extend", (PyCFunction)UIEntityCollection::extend, METH_O,
|
||||||
{"count", (PyCFunction)UIEntityCollection::count, METH_O},
|
"extend(iterable)\n\n"
|
||||||
|
"Add all entities from an iterable to the collection."},
|
||||||
|
{"insert", (PyCFunction)UIEntityCollection::insert, METH_VARARGS,
|
||||||
|
"insert(index, entity)\n\n"
|
||||||
|
"Insert entity at index. Like list.insert(), indices past the end append."},
|
||||||
|
{"remove", (PyCFunction)UIEntityCollection::remove, METH_O,
|
||||||
|
"remove(entity)\n\n"
|
||||||
|
"Remove first occurrence of entity. Raises ValueError if not found."},
|
||||||
|
{"pop", (PyCFunction)UIEntityCollection::pop, METH_VARARGS,
|
||||||
|
"pop([index]) -> entity\n\n"
|
||||||
|
"Remove and return entity at index (default: last entity)."},
|
||||||
|
{"index", (PyCFunction)UIEntityCollection::index_method, METH_O,
|
||||||
|
"index(entity) -> int\n\n"
|
||||||
|
"Return index of first occurrence of entity. Raises ValueError if not found."},
|
||||||
|
{"count", (PyCFunction)UIEntityCollection::count, METH_O,
|
||||||
|
"count(entity) -> int\n\n"
|
||||||
|
"Count occurrences of entity in the collection."},
|
||||||
|
{"find", (PyCFunction)UIEntityCollection::find, METH_VARARGS | METH_KEYWORDS,
|
||||||
|
"find(name) -> entity or list\n\n"
|
||||||
|
"Find entities by name.\n\n"
|
||||||
|
"Args:\n"
|
||||||
|
" name (str): Name to search for. Supports wildcards:\n"
|
||||||
|
" - 'exact' for exact match (returns single entity or None)\n"
|
||||||
|
" - 'prefix*' for starts-with match (returns list)\n"
|
||||||
|
" - '*suffix' for ends-with match (returns list)\n"
|
||||||
|
" - '*substring*' for contains match (returns list)\n\n"
|
||||||
|
"Returns:\n"
|
||||||
|
" Single entity if exact match, list if wildcard, None if not found."},
|
||||||
{NULL, NULL, 0, NULL}
|
{NULL, NULL, 0, NULL}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,8 +141,11 @@ public:
|
||||||
static PyObject* append(PyUIEntityCollectionObject* self, PyObject* o);
|
static PyObject* append(PyUIEntityCollectionObject* self, PyObject* o);
|
||||||
static PyObject* extend(PyUIEntityCollectionObject* self, PyObject* o);
|
static PyObject* extend(PyUIEntityCollectionObject* self, PyObject* o);
|
||||||
static PyObject* remove(PyUIEntityCollectionObject* self, PyObject* o);
|
static PyObject* remove(PyUIEntityCollectionObject* self, PyObject* o);
|
||||||
|
static PyObject* pop(PyUIEntityCollectionObject* self, PyObject* args);
|
||||||
|
static PyObject* insert(PyUIEntityCollectionObject* self, PyObject* args);
|
||||||
static PyObject* index_method(PyUIEntityCollectionObject* self, PyObject* value);
|
static PyObject* index_method(PyUIEntityCollectionObject* self, PyObject* value);
|
||||||
static PyObject* count(PyUIEntityCollectionObject* self, PyObject* value);
|
static PyObject* count(PyUIEntityCollectionObject* self, PyObject* value);
|
||||||
|
static PyObject* find(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds);
|
||||||
static PyMethodDef methods[];
|
static PyMethodDef methods[];
|
||||||
static PyObject* repr(PyUIEntityCollectionObject* self);
|
static PyObject* repr(PyUIEntityCollectionObject* self);
|
||||||
static int init(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds);
|
static int init(PyUIEntityCollectionObject* self, PyObject* args, PyObject* kwds);
|
||||||
|
|
|
||||||
15
src/main.cpp
15
src/main.cpp
|
|
@ -44,6 +44,11 @@ int run_game_engine(const McRogueFaceConfig& config)
|
||||||
if (Py_IsInitialized()) {
|
if (Py_IsInitialized()) {
|
||||||
McRFPy_API::api_shutdown();
|
McRFPy_API::api_shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return exception exit code if a Python exception signaled exit
|
||||||
|
if (McRFPy_API::shouldExit()) {
|
||||||
|
return McRFPy_API::exit_code.load();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,9 +186,13 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv
|
||||||
|
|
||||||
// Run the game engine after script execution
|
// Run the game engine after script execution
|
||||||
engine->run();
|
engine->run();
|
||||||
|
|
||||||
McRFPy_API::api_shutdown();
|
McRFPy_API::api_shutdown();
|
||||||
delete engine;
|
delete engine;
|
||||||
|
// Return exception exit code if signaled
|
||||||
|
if (McRFPy_API::shouldExit()) {
|
||||||
|
return McRFPy_API::exit_code.load();
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
else if (config.interactive_mode) {
|
else if (config.interactive_mode) {
|
||||||
|
|
@ -207,6 +216,10 @@ int run_python_interpreter(const McRogueFaceConfig& config, int argc, char* argv
|
||||||
engine->run();
|
engine->run();
|
||||||
McRFPy_API::api_shutdown();
|
McRFPy_API::api_shutdown();
|
||||||
delete engine;
|
delete engine;
|
||||||
|
// Return exception exit code if signaled
|
||||||
|
if (McRFPy_API::shouldExit()) {
|
||||||
|
return McRFPy_API::exit_code.load();
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
31
tests/notes/test_exception_exit.py
Normal file
31
tests/notes/test_exception_exit.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test for --continue-after-exceptions behavior (Issue #133)
|
||||||
|
|
||||||
|
This test verifies that:
|
||||||
|
1. By default, unhandled exceptions in timer callbacks cause immediate exit with code 1
|
||||||
|
2. With --continue-after-exceptions, exceptions are logged but execution continues
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def timer_that_raises(runtime):
|
||||||
|
"""A timer callback that raises an exception"""
|
||||||
|
raise ValueError("Intentional test exception")
|
||||||
|
|
||||||
|
# Create a test scene
|
||||||
|
mcrfpy.createScene("test")
|
||||||
|
mcrfpy.setScene("test")
|
||||||
|
|
||||||
|
# Schedule the timer - it will fire after 50ms
|
||||||
|
mcrfpy.setTimer("raise_exception", timer_that_raises, 50)
|
||||||
|
|
||||||
|
# This test expects:
|
||||||
|
# - Default behavior: exit with code 1 after first exception
|
||||||
|
# - With --continue-after-exceptions: continue running (would need timeout or explicit exit)
|
||||||
|
#
|
||||||
|
# The test runner should:
|
||||||
|
# 1. Run without --continue-after-exceptions and expect exit code 1
|
||||||
|
# 2. Run with --continue-after-exceptions and expect it to not exit immediately
|
||||||
|
|
||||||
|
print("Test initialized - timer will raise exception in 50ms")
|
||||||
38
tests/notes/test_exception_exit_manual.sh
Executable file
38
tests/notes/test_exception_exit_manual.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Manual test for --continue-after-exceptions feature (Issue #133)
|
||||||
|
#
|
||||||
|
# This test must be run manually because it verifies exit codes
|
||||||
|
# rather than test output.
|
||||||
|
|
||||||
|
echo "Testing --continue-after-exceptions feature..."
|
||||||
|
echo
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../../build"
|
||||||
|
|
||||||
|
# Test 1: Default behavior - should exit with code 1 on first exception
|
||||||
|
echo "Test 1: Default behavior (exit on first exception)"
|
||||||
|
timeout 5 ./mcrogueface --headless --exec ../tests/notes/test_exception_exit.py 2>&1
|
||||||
|
EXIT_CODE=$?
|
||||||
|
echo "Exit code: $EXIT_CODE"
|
||||||
|
if [ $EXIT_CODE -eq 1 ]; then
|
||||||
|
echo "[PASS] Exit code is 1 as expected"
|
||||||
|
else
|
||||||
|
echo "[FAIL] Expected exit code 1, got $EXIT_CODE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
# Test 2: --continue-after-exceptions - should keep running until timeout
|
||||||
|
echo "Test 2: --continue-after-exceptions (continue after exception)"
|
||||||
|
timeout 1 ./mcrogueface --headless --continue-after-exceptions --exec ../tests/notes/test_exception_exit.py 2>&1 | tail -5
|
||||||
|
EXIT_CODE=${PIPESTATUS[0]}
|
||||||
|
echo "Exit code: $EXIT_CODE"
|
||||||
|
if [ $EXIT_CODE -eq 124 ]; then
|
||||||
|
echo "[PASS] Timeout killed it (exit code 124) - continued running as expected"
|
||||||
|
else
|
||||||
|
echo "[FAIL] Expected exit code 124 (timeout), got $EXIT_CODE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "All tests PASSED!"
|
||||||
209
tests/unit/collection_find_test.py
Normal file
209
tests/unit/collection_find_test.py
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test for UICollection.find() and EntityCollection.find() methods.
|
||||||
|
|
||||||
|
Tests issue #40 (search and replace by name) and #41 (.find on collections).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
|
def test_uicollection_find():
|
||||||
|
"""Test UICollection.find() with exact and wildcard matches."""
|
||||||
|
print("Testing UICollection.find()...")
|
||||||
|
|
||||||
|
# Create a scene with named elements
|
||||||
|
mcrfpy.createScene("test_find")
|
||||||
|
ui = mcrfpy.sceneUI("test_find")
|
||||||
|
|
||||||
|
# Create frames with names
|
||||||
|
frame1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
|
frame1.name = "main_frame"
|
||||||
|
ui.append(frame1)
|
||||||
|
|
||||||
|
frame2 = mcrfpy.Frame(pos=(100, 0), size=(100, 100))
|
||||||
|
frame2.name = "sidebar_frame"
|
||||||
|
ui.append(frame2)
|
||||||
|
|
||||||
|
frame3 = mcrfpy.Frame(pos=(200, 0), size=(100, 100))
|
||||||
|
frame3.name = "player_status"
|
||||||
|
ui.append(frame3)
|
||||||
|
|
||||||
|
caption1 = mcrfpy.Caption(text="Hello", pos=(0, 200))
|
||||||
|
caption1.name = "player_name"
|
||||||
|
ui.append(caption1)
|
||||||
|
|
||||||
|
# Create an unnamed element
|
||||||
|
unnamed = mcrfpy.Caption(text="Unnamed", pos=(0, 250))
|
||||||
|
ui.append(unnamed)
|
||||||
|
|
||||||
|
# Test exact match - found
|
||||||
|
result = ui.find("main_frame")
|
||||||
|
assert result is not None, "Exact match should find element"
|
||||||
|
assert result.name == "main_frame", f"Found wrong element: {result.name}"
|
||||||
|
print(" [PASS] Exact match found")
|
||||||
|
|
||||||
|
# Test exact match - not found
|
||||||
|
result = ui.find("nonexistent")
|
||||||
|
assert result is None, "Should return None when not found"
|
||||||
|
print(" [PASS] Not found returns None")
|
||||||
|
|
||||||
|
# Test prefix wildcard (starts with)
|
||||||
|
results = ui.find("player*")
|
||||||
|
assert isinstance(results, list), "Wildcard should return list"
|
||||||
|
assert len(results) == 2, f"Expected 2 matches, got {len(results)}"
|
||||||
|
names = [r.name for r in results]
|
||||||
|
assert "player_status" in names, "player_status should match player*"
|
||||||
|
assert "player_name" in names, "player_name should match player*"
|
||||||
|
print(" [PASS] Prefix wildcard works")
|
||||||
|
|
||||||
|
# Test suffix wildcard (ends with)
|
||||||
|
results = ui.find("*_frame")
|
||||||
|
assert isinstance(results, list), "Wildcard should return list"
|
||||||
|
assert len(results) == 2, f"Expected 2 matches, got {len(results)}"
|
||||||
|
names = [r.name for r in results]
|
||||||
|
assert "main_frame" in names
|
||||||
|
assert "sidebar_frame" in names
|
||||||
|
print(" [PASS] Suffix wildcard works")
|
||||||
|
|
||||||
|
# Test contains wildcard
|
||||||
|
results = ui.find("*bar*")
|
||||||
|
assert isinstance(results, list), "Wildcard should return list"
|
||||||
|
assert len(results) == 1, f"Expected 1 match, got {len(results)}"
|
||||||
|
assert results[0].name == "sidebar_frame"
|
||||||
|
print(" [PASS] Contains wildcard works")
|
||||||
|
|
||||||
|
# Test match all
|
||||||
|
results = ui.find("*")
|
||||||
|
# Should match all named elements (4 named + 1 unnamed with empty name)
|
||||||
|
assert isinstance(results, list), "Match all should return list"
|
||||||
|
assert len(results) == 5, f"Expected 5 matches, got {len(results)}"
|
||||||
|
print(" [PASS] Match all wildcard works")
|
||||||
|
|
||||||
|
# Test empty pattern matches elements with empty names (unnamed elements)
|
||||||
|
result = ui.find("")
|
||||||
|
# The unnamed caption has an empty name, so exact match should find it
|
||||||
|
assert result is not None, "Empty name exact match should find the unnamed element"
|
||||||
|
print(" [PASS] Empty pattern finds unnamed elements")
|
||||||
|
|
||||||
|
print("UICollection.find() tests passed!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_entitycollection_find():
|
||||||
|
"""Test EntityCollection.find() with exact and wildcard matches."""
|
||||||
|
print("\nTesting EntityCollection.find()...")
|
||||||
|
|
||||||
|
# Create a grid with entities
|
||||||
|
mcrfpy.createScene("test_entity_find")
|
||||||
|
ui = mcrfpy.sceneUI("test_entity_find")
|
||||||
|
|
||||||
|
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(400, 400))
|
||||||
|
ui.append(grid)
|
||||||
|
|
||||||
|
# Add named entities
|
||||||
|
player = mcrfpy.Entity(grid_pos=(1, 1))
|
||||||
|
player.name = "player"
|
||||||
|
grid.entities.append(player)
|
||||||
|
|
||||||
|
enemy1 = mcrfpy.Entity(grid_pos=(2, 2))
|
||||||
|
enemy1.name = "enemy_goblin"
|
||||||
|
grid.entities.append(enemy1)
|
||||||
|
|
||||||
|
enemy2 = mcrfpy.Entity(grid_pos=(3, 3))
|
||||||
|
enemy2.name = "enemy_orc"
|
||||||
|
grid.entities.append(enemy2)
|
||||||
|
|
||||||
|
item = mcrfpy.Entity(grid_pos=(4, 4))
|
||||||
|
item.name = "item_sword"
|
||||||
|
grid.entities.append(item)
|
||||||
|
|
||||||
|
# Test exact match
|
||||||
|
result = grid.entities.find("player")
|
||||||
|
assert result is not None, "Should find player"
|
||||||
|
assert result.name == "player"
|
||||||
|
print(" [PASS] Entity exact match works")
|
||||||
|
|
||||||
|
# Test not found
|
||||||
|
result = grid.entities.find("boss")
|
||||||
|
assert result is None, "Should return None when not found"
|
||||||
|
print(" [PASS] Entity not found returns None")
|
||||||
|
|
||||||
|
# Test prefix wildcard
|
||||||
|
results = grid.entities.find("enemy*")
|
||||||
|
assert isinstance(results, list)
|
||||||
|
assert len(results) == 2, f"Expected 2 enemies, got {len(results)}"
|
||||||
|
print(" [PASS] Entity prefix wildcard works")
|
||||||
|
|
||||||
|
# Test suffix wildcard
|
||||||
|
results = grid.entities.find("*_orc")
|
||||||
|
assert isinstance(results, list)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].name == "enemy_orc"
|
||||||
|
print(" [PASS] Entity suffix wildcard works")
|
||||||
|
|
||||||
|
print("EntityCollection.find() tests passed!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_recursive_find():
|
||||||
|
"""Test recursive find in nested Frame children."""
|
||||||
|
print("\nTesting recursive find in nested frames...")
|
||||||
|
|
||||||
|
mcrfpy.createScene("test_recursive")
|
||||||
|
ui = mcrfpy.sceneUI("test_recursive")
|
||||||
|
|
||||||
|
# Create nested structure
|
||||||
|
parent = mcrfpy.Frame(pos=(0, 0), size=(400, 400))
|
||||||
|
parent.name = "parent"
|
||||||
|
ui.append(parent)
|
||||||
|
|
||||||
|
child = mcrfpy.Frame(pos=(10, 10), size=(200, 200))
|
||||||
|
child.name = "child_frame"
|
||||||
|
parent.children.append(child)
|
||||||
|
|
||||||
|
grandchild = mcrfpy.Caption(text="Deep", pos=(5, 5))
|
||||||
|
grandchild.name = "deep_caption"
|
||||||
|
child.children.append(grandchild)
|
||||||
|
|
||||||
|
# Non-recursive find should not find nested elements
|
||||||
|
result = ui.find("deep_caption")
|
||||||
|
assert result is None, "Non-recursive find should not find nested element"
|
||||||
|
print(" [PASS] Non-recursive doesn't find nested elements")
|
||||||
|
|
||||||
|
# Recursive find should find nested elements
|
||||||
|
result = ui.find("deep_caption", recursive=True)
|
||||||
|
assert result is not None, "Recursive find should find nested element"
|
||||||
|
assert result.name == "deep_caption"
|
||||||
|
print(" [PASS] Recursive find locates nested elements")
|
||||||
|
|
||||||
|
# Recursive wildcard should find all matches
|
||||||
|
results = ui.find("*_frame", recursive=True)
|
||||||
|
assert isinstance(results, list)
|
||||||
|
names = [r.name for r in results]
|
||||||
|
assert "child_frame" in names, "Should find child_frame"
|
||||||
|
print(" [PASS] Recursive wildcard finds nested matches")
|
||||||
|
|
||||||
|
print("Recursive find tests passed!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
all_passed = True
|
||||||
|
all_passed &= test_uicollection_find()
|
||||||
|
all_passed &= test_entitycollection_find()
|
||||||
|
all_passed &= test_recursive_find()
|
||||||
|
|
||||||
|
if all_passed:
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("All find() tests PASSED!")
|
||||||
|
print("="*50)
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("\nSome tests FAILED!")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nTest failed with exception: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
263
tests/unit/collection_list_methods_test.py
Normal file
263
tests/unit/collection_list_methods_test.py
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Test for Python list-like methods on UICollection and EntityCollection.
|
||||||
|
|
||||||
|
Tests that remove(), pop(), insert(), index(), count() match Python list semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def test_uicollection_remove():
|
||||||
|
"""Test UICollection.remove() takes a value, not an index."""
|
||||||
|
print("Testing UICollection.remove()...")
|
||||||
|
|
||||||
|
mcrfpy.createScene("test_remove")
|
||||||
|
ui = mcrfpy.sceneUI("test_remove")
|
||||||
|
|
||||||
|
frame1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
|
frame2 = mcrfpy.Frame(pos=(100, 0), size=(100, 100))
|
||||||
|
frame3 = mcrfpy.Frame(pos=(200, 0), size=(100, 100))
|
||||||
|
|
||||||
|
ui.append(frame1)
|
||||||
|
ui.append(frame2)
|
||||||
|
ui.append(frame3)
|
||||||
|
|
||||||
|
assert len(ui) == 3
|
||||||
|
|
||||||
|
# Remove by value (like Python list)
|
||||||
|
ui.remove(frame2)
|
||||||
|
assert len(ui) == 2
|
||||||
|
print(" [PASS] remove(element) works")
|
||||||
|
|
||||||
|
# Verify frame2 is gone, but frame1 and frame3 remain
|
||||||
|
assert ui[0] is not None
|
||||||
|
assert ui[1] is not None
|
||||||
|
|
||||||
|
# Try to remove something not in the list
|
||||||
|
try:
|
||||||
|
frame4 = mcrfpy.Frame(pos=(300, 0), size=(100, 100))
|
||||||
|
ui.remove(frame4)
|
||||||
|
assert False, "Should have raised ValueError"
|
||||||
|
except ValueError as e:
|
||||||
|
assert "not in" in str(e).lower()
|
||||||
|
print(" [PASS] remove() raises ValueError when not found")
|
||||||
|
|
||||||
|
# Try to pass an integer (should fail - no longer takes index)
|
||||||
|
try:
|
||||||
|
ui.remove(0)
|
||||||
|
assert False, "Should have raised TypeError"
|
||||||
|
except TypeError:
|
||||||
|
print(" [PASS] remove(int) raises TypeError (correct - takes element, not index)")
|
||||||
|
|
||||||
|
print("UICollection.remove() tests passed!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_uicollection_pop():
|
||||||
|
"""Test UICollection.pop() removes and returns element at index."""
|
||||||
|
print("\nTesting UICollection.pop()...")
|
||||||
|
|
||||||
|
mcrfpy.createScene("test_pop")
|
||||||
|
ui = mcrfpy.sceneUI("test_pop")
|
||||||
|
|
||||||
|
frame1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
|
frame1.name = "first"
|
||||||
|
frame2 = mcrfpy.Frame(pos=(100, 0), size=(100, 100))
|
||||||
|
frame2.name = "second"
|
||||||
|
frame3 = mcrfpy.Frame(pos=(200, 0), size=(100, 100))
|
||||||
|
frame3.name = "third"
|
||||||
|
|
||||||
|
ui.append(frame1)
|
||||||
|
ui.append(frame2)
|
||||||
|
ui.append(frame3)
|
||||||
|
|
||||||
|
# pop() with no args removes last
|
||||||
|
popped = ui.pop()
|
||||||
|
assert popped.name == "third", f"Expected 'third', got '{popped.name}'"
|
||||||
|
assert len(ui) == 2
|
||||||
|
print(" [PASS] pop() removes last element")
|
||||||
|
|
||||||
|
# pop(0) removes first
|
||||||
|
popped = ui.pop(0)
|
||||||
|
assert popped.name == "first", f"Expected 'first', got '{popped.name}'"
|
||||||
|
assert len(ui) == 1
|
||||||
|
print(" [PASS] pop(0) removes first element")
|
||||||
|
|
||||||
|
# pop(-1) is same as pop()
|
||||||
|
ui.append(mcrfpy.Frame(pos=(0, 0), size=(10, 10)))
|
||||||
|
ui[-1].name = "new_last"
|
||||||
|
popped = ui.pop(-1)
|
||||||
|
assert popped.name == "new_last"
|
||||||
|
print(" [PASS] pop(-1) removes last element")
|
||||||
|
|
||||||
|
# pop from empty collection
|
||||||
|
ui.pop() # Remove last remaining element
|
||||||
|
try:
|
||||||
|
ui.pop()
|
||||||
|
assert False, "Should have raised IndexError"
|
||||||
|
except IndexError as e:
|
||||||
|
assert "empty" in str(e).lower()
|
||||||
|
print(" [PASS] pop() from empty raises IndexError")
|
||||||
|
|
||||||
|
print("UICollection.pop() tests passed!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_uicollection_insert():
|
||||||
|
"""Test UICollection.insert() inserts at given index."""
|
||||||
|
print("\nTesting UICollection.insert()...")
|
||||||
|
|
||||||
|
mcrfpy.createScene("test_insert")
|
||||||
|
ui = mcrfpy.sceneUI("test_insert")
|
||||||
|
|
||||||
|
frame1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
|
frame1.name = "first"
|
||||||
|
frame3 = mcrfpy.Frame(pos=(200, 0), size=(100, 100))
|
||||||
|
frame3.name = "third"
|
||||||
|
|
||||||
|
ui.append(frame1)
|
||||||
|
ui.append(frame3)
|
||||||
|
|
||||||
|
# Insert in middle
|
||||||
|
frame2 = mcrfpy.Frame(pos=(100, 0), size=(100, 100))
|
||||||
|
frame2.name = "second"
|
||||||
|
ui.insert(1, frame2)
|
||||||
|
|
||||||
|
assert len(ui) == 3
|
||||||
|
assert ui[0].name == "first"
|
||||||
|
assert ui[1].name == "second"
|
||||||
|
assert ui[2].name == "third"
|
||||||
|
print(" [PASS] insert(1, element) inserts at index 1")
|
||||||
|
|
||||||
|
# Insert at beginning
|
||||||
|
frame0 = mcrfpy.Frame(pos=(0, 0), size=(50, 50))
|
||||||
|
frame0.name = "zero"
|
||||||
|
ui.insert(0, frame0)
|
||||||
|
assert ui[0].name == "zero"
|
||||||
|
print(" [PASS] insert(0, element) inserts at beginning")
|
||||||
|
|
||||||
|
# Insert at end (index > len)
|
||||||
|
frame_end = mcrfpy.Frame(pos=(0, 0), size=(50, 50))
|
||||||
|
frame_end.name = "end"
|
||||||
|
ui.insert(100, frame_end) # Way past end
|
||||||
|
assert ui[-1].name == "end"
|
||||||
|
print(" [PASS] insert(100, element) appends when index > len")
|
||||||
|
|
||||||
|
# Negative index
|
||||||
|
frame_neg = mcrfpy.Frame(pos=(0, 0), size=(50, 50))
|
||||||
|
frame_neg.name = "negative"
|
||||||
|
current_len = len(ui)
|
||||||
|
ui.insert(-1, frame_neg) # Insert before last
|
||||||
|
assert ui[-2].name == "negative"
|
||||||
|
print(" [PASS] insert(-1, element) inserts before last")
|
||||||
|
|
||||||
|
print("UICollection.insert() tests passed!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_entitycollection_pop_insert():
|
||||||
|
"""Test EntityCollection.pop() and insert()."""
|
||||||
|
print("\nTesting EntityCollection.pop() and insert()...")
|
||||||
|
|
||||||
|
mcrfpy.createScene("test_entity_pop")
|
||||||
|
ui = mcrfpy.sceneUI("test_entity_pop")
|
||||||
|
|
||||||
|
grid = mcrfpy.Grid(grid_size=(10, 10), pos=(0, 0), size=(400, 400))
|
||||||
|
ui.append(grid)
|
||||||
|
|
||||||
|
e1 = mcrfpy.Entity(grid_pos=(1, 1))
|
||||||
|
e1.name = "first"
|
||||||
|
e2 = mcrfpy.Entity(grid_pos=(2, 2))
|
||||||
|
e2.name = "second"
|
||||||
|
e3 = mcrfpy.Entity(grid_pos=(3, 3))
|
||||||
|
e3.name = "third"
|
||||||
|
|
||||||
|
grid.entities.append(e1)
|
||||||
|
grid.entities.append(e2)
|
||||||
|
grid.entities.append(e3)
|
||||||
|
|
||||||
|
# Test pop()
|
||||||
|
popped = grid.entities.pop()
|
||||||
|
assert popped.name == "third"
|
||||||
|
assert len(grid.entities) == 2
|
||||||
|
print(" [PASS] EntityCollection.pop() works")
|
||||||
|
|
||||||
|
# Test pop(0)
|
||||||
|
popped = grid.entities.pop(0)
|
||||||
|
assert popped.name == "first"
|
||||||
|
assert len(grid.entities) == 1
|
||||||
|
print(" [PASS] EntityCollection.pop(0) works")
|
||||||
|
|
||||||
|
# Test insert
|
||||||
|
e_new = mcrfpy.Entity(grid_pos=(5, 5))
|
||||||
|
e_new.name = "new"
|
||||||
|
grid.entities.insert(0, e_new)
|
||||||
|
assert grid.entities[0].name == "new"
|
||||||
|
print(" [PASS] EntityCollection.insert() works")
|
||||||
|
|
||||||
|
print("EntityCollection pop/insert tests passed!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_and_count():
|
||||||
|
"""Test index() and count() methods."""
|
||||||
|
print("\nTesting index() and count()...")
|
||||||
|
|
||||||
|
mcrfpy.createScene("test_index_count")
|
||||||
|
ui = mcrfpy.sceneUI("test_index_count")
|
||||||
|
|
||||||
|
frame1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
|
frame2 = mcrfpy.Frame(pos=(100, 0), size=(100, 100))
|
||||||
|
|
||||||
|
ui.append(frame1)
|
||||||
|
ui.append(frame2)
|
||||||
|
|
||||||
|
# index() returns integer
|
||||||
|
idx = ui.index(frame1)
|
||||||
|
assert idx == 0, f"Expected 0, got {idx}"
|
||||||
|
assert isinstance(idx, int)
|
||||||
|
print(" [PASS] index() returns integer")
|
||||||
|
|
||||||
|
idx = ui.index(frame2)
|
||||||
|
assert idx == 1
|
||||||
|
print(" [PASS] index() finds correct position")
|
||||||
|
|
||||||
|
# count() returns integer
|
||||||
|
cnt = ui.count(frame1)
|
||||||
|
assert cnt == 1
|
||||||
|
assert isinstance(cnt, int)
|
||||||
|
print(" [PASS] count() returns integer")
|
||||||
|
|
||||||
|
# count of element not in collection
|
||||||
|
frame3 = mcrfpy.Frame(pos=(200, 0), size=(100, 100))
|
||||||
|
cnt = ui.count(frame3)
|
||||||
|
assert cnt == 0
|
||||||
|
print(" [PASS] count() returns 0 for element not in collection")
|
||||||
|
|
||||||
|
print("index() and count() tests passed!")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
all_passed = True
|
||||||
|
all_passed &= test_uicollection_remove()
|
||||||
|
all_passed &= test_uicollection_pop()
|
||||||
|
all_passed &= test_uicollection_insert()
|
||||||
|
all_passed &= test_entitycollection_pop_insert()
|
||||||
|
all_passed &= test_index_and_count()
|
||||||
|
|
||||||
|
if all_passed:
|
||||||
|
print("\n" + "="*50)
|
||||||
|
print("All list-like method tests PASSED!")
|
||||||
|
print("="*50)
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("\nSome tests FAILED!")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nTest failed with exception: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
@ -92,9 +92,9 @@ def run_tests(runtime):
|
||||||
test(retrieved_caption.caption_id == "test_caption", "Caption custom data preserved")
|
test(retrieved_caption.caption_id == "test_caption", "Caption custom data preserved")
|
||||||
|
|
||||||
# Test 8: Test removal and re-addition
|
# Test 8: Test removal and re-addition
|
||||||
#scene_ui.remove(frame) # TypeError: UICollection.remove requires an integer index to remove - seems like a C++ bug in the remove() implementation
|
# Use del to remove by index (Python standard), or .remove(element) to remove by value
|
||||||
print(f"before remove: {len(scene_ui)=}")
|
print(f"before remove: {len(scene_ui)=}")
|
||||||
scene_ui.remove(-1)
|
del scene_ui[-1] # Remove last element by index
|
||||||
print(f"after remove: {len(scene_ui)=}")
|
print(f"after remove: {len(scene_ui)=}")
|
||||||
|
|
||||||
scene_ui.append(frame)
|
scene_ui.append(frame)
|
||||||
|
|
|
||||||
203
tests/unit/test_vector_convenience.py
Normal file
203
tests/unit/test_vector_convenience.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue