From 001cc6efd6f0f1de9436a5b7297914f4e58be9b0 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Tue, 3 Feb 2026 19:52:44 -0500 Subject: [PATCH] grid layer API modernization --- src/GridLayers.cpp | 286 +++++++++++++++- src/GridLayers.h | 59 ++-- src/UIGrid.cpp | 539 +++++++++++++++++++++--------- src/UIGrid.h | 2 +- tests/unit/grid_layer_api_test.py | 275 +++++++++++++++ 5 files changed, 977 insertions(+), 184 deletions(-) create mode 100644 tests/unit/grid_layer_api_test.py diff --git a/src/GridLayers.cpp b/src/GridLayers.cpp index 3ae122c..f415764 100644 --- a/src/GridLayers.cpp +++ b/src/GridLayers.cpp @@ -803,17 +803,23 @@ PyGetSetDef PyGridLayerAPI::ColorLayer_getsetters[] = { "Whether the layer is rendered.", NULL}, {"grid_size", (getter)PyGridLayerAPI::ColorLayer_get_grid_size, NULL, "Layer dimensions as (width, height) tuple.", NULL}, + {"name", (getter)PyGridLayerAPI::ColorLayer_get_name, NULL, + "Layer name (str, read-only). Used for Grid.layer(name) lookup.", NULL}, + {"grid", (getter)PyGridLayerAPI::ColorLayer_get_grid, + (setter)PyGridLayerAPI::ColorLayer_set_grid, + "Parent Grid or None. Setting manages layer association and handles lazy allocation.", NULL}, {NULL} }; int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"z_index", "grid_size", NULL}; + static const char* kwlist[] = {"z_index", "name", "grid_size", NULL}; int z_index = -1; + const char* name_str = nullptr; PyObject* grid_size_obj = nullptr; int grid_x = 0, grid_y = 0; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iO", const_cast(kwlist), - &z_index, &grid_size_obj)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|izO", const_cast(kwlist), + &z_index, &name_str, &grid_size_obj)) { return -1; } @@ -836,8 +842,11 @@ int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, Py } } - // Create the layer (will be attached to grid via add_layer) + // Create the layer (will be attached to grid via add_layer or layer.grid setter) self->data = std::make_shared(z_index, grid_x, grid_y, nullptr); + if (name_str) { + self->data->name = name_str; + } self->grid.reset(); return 0; @@ -1598,12 +1607,137 @@ PyObject* PyGridLayerAPI::ColorLayer_get_grid_size(PyColorLayerObject* self, voi return Py_BuildValue("(ii)", self->data->grid_x, self->data->grid_y); } +PyObject* PyGridLayerAPI::ColorLayer_get_name(PyColorLayerObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + return PyUnicode_FromString(self->data->name.c_str()); +} + +PyObject* PyGridLayerAPI::ColorLayer_get_grid(PyColorLayerObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + + // Check actual parent_grid pointer - it may have been cleared by name collision handling + if (!self->data->parent_grid) { + // Sync Python wrapper's state if the C++ layer was unlinked externally + self->grid.reset(); + Py_RETURN_NONE; + } + + if (!self->grid) { + Py_RETURN_NONE; + } + + // Create Python Grid wrapper for the parent grid + auto* grid_type = (PyTypeObject*)PyObject_GetAttrString( + PyImport_ImportModule("mcrfpy"), "Grid"); + if (!grid_type) return NULL; + + PyUIGridObject* py_grid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0); + Py_DECREF(grid_type); + if (!py_grid) return NULL; + + py_grid->data = self->grid; + py_grid->weakreflist = NULL; + return (PyObject*)py_grid; +} + +int PyGridLayerAPI::ColorLayer_set_grid(PyColorLayerObject* self, PyObject* value, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return -1; + } + + // Handle None - unlink from current grid + if (value == Py_None || value == NULL) { + if (self->grid) { + self->data->parent_grid = nullptr; + self->grid->removeLayer(self->data); + self->grid.reset(); + } + return 0; + } + + // Validate it's a Grid + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return -1; + + auto* grid_type = PyObject_GetAttrString(mcrfpy_module, "Grid"); + Py_DECREF(mcrfpy_module); + if (!grid_type) return -1; + + if (!PyObject_IsInstance(value, grid_type)) { + Py_DECREF(grid_type); + PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None"); + return -1; + } + Py_DECREF(grid_type); + + PyUIGridObject* py_grid = (PyUIGridObject*)value; + + // Check if already attached to this grid + if (self->grid.get() == py_grid->data.get()) { + return 0; // Nothing to do + } + + // Unlink from old grid if any + if (self->grid) { + self->data->parent_grid = nullptr; + self->grid->removeLayer(self->data); + } + + // Check for protected names + if (!self->data->name.empty() && UIGrid::isProtectedLayerName(self->data->name)) { + PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", self->data->name.c_str()); + self->grid.reset(); + return -1; + } + + // Handle name collision - unlink existing layer with same name + if (!self->data->name.empty()) { + auto existing = py_grid->data->getLayerByName(self->data->name); + if (existing && existing.get() != self->data.get()) { + existing->parent_grid = nullptr; + py_grid->data->removeLayer(existing); + } + } + + // Lazy allocation: resize if layer is (0,0) + if (self->data->grid_x == 0 && self->data->grid_y == 0) { + self->data->resize(py_grid->data->grid_w, py_grid->data->grid_h); + } else if (self->data->grid_x != py_grid->data->grid_w || + self->data->grid_y != py_grid->data->grid_h) { + PyErr_Format(PyExc_ValueError, + "Layer size (%d, %d) does not match Grid size (%d, %d)", + self->data->grid_x, self->data->grid_y, + py_grid->data->grid_w, py_grid->data->grid_h); + self->grid.reset(); + return -1; + } + + // Link to new grid + self->data->parent_grid = py_grid->data.get(); + py_grid->data->layers.push_back(self->data); + py_grid->data->layers_need_sort = true; + self->grid = py_grid->data; + + return 0; +} + PyObject* PyGridLayerAPI::ColorLayer_repr(PyColorLayerObject* self) { std::ostringstream ss; if (!self->data) { ss << ""; } else { - ss << "data->name.empty()) { + ss << " '" << self->data->name << "'"; + } + ss << " z_index=" << self->data->z_index << " size=(" << self->data->grid_x << "x" << self->data->grid_y << ")" << " visible=" << (self->data->visible ? "True" : "False") << ">"; } @@ -1679,18 +1813,24 @@ PyGetSetDef PyGridLayerAPI::TileLayer_getsetters[] = { "Texture atlas for tile sprites.", NULL}, {"grid_size", (getter)PyGridLayerAPI::TileLayer_get_grid_size, NULL, "Layer dimensions as (width, height) tuple.", NULL}, + {"name", (getter)PyGridLayerAPI::TileLayer_get_name, NULL, + "Layer name (str, read-only). Used for Grid.layer(name) lookup.", NULL}, + {"grid", (getter)PyGridLayerAPI::TileLayer_get_grid, + (setter)PyGridLayerAPI::TileLayer_set_grid, + "Parent Grid or None. Setting manages layer association and handles lazy allocation.", NULL}, {NULL} }; int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"z_index", "texture", "grid_size", NULL}; + static const char* kwlist[] = {"z_index", "name", "texture", "grid_size", NULL}; int z_index = -1; + const char* name_str = nullptr; PyObject* texture_obj = nullptr; PyObject* grid_size_obj = nullptr; int grid_x = 0, grid_y = 0; - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iOO", const_cast(kwlist), - &z_index, &texture_obj, &grid_size_obj)) { + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|izOO", const_cast(kwlist), + &z_index, &name_str, &texture_obj, &grid_size_obj)) { return -1; } @@ -1736,6 +1876,9 @@ int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyOb // Create the layer self->data = std::make_shared(z_index, grid_x, grid_y, nullptr, texture); + if (name_str) { + self->data->name = name_str; + } self->grid.reset(); return 0; @@ -2099,12 +2242,137 @@ PyObject* PyGridLayerAPI::TileLayer_get_grid_size(PyTileLayerObject* self, void* return Py_BuildValue("(ii)", self->data->grid_x, self->data->grid_y); } +PyObject* PyGridLayerAPI::TileLayer_get_name(PyTileLayerObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + return PyUnicode_FromString(self->data->name.c_str()); +} + +PyObject* PyGridLayerAPI::TileLayer_get_grid(PyTileLayerObject* self, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + + // Check actual parent_grid pointer - it may have been cleared by name collision handling + if (!self->data->parent_grid) { + // Sync Python wrapper's state if the C++ layer was unlinked externally + self->grid.reset(); + Py_RETURN_NONE; + } + + if (!self->grid) { + Py_RETURN_NONE; + } + + // Create Python Grid wrapper for the parent grid + auto* grid_type = (PyTypeObject*)PyObject_GetAttrString( + PyImport_ImportModule("mcrfpy"), "Grid"); + if (!grid_type) return NULL; + + PyUIGridObject* py_grid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0); + Py_DECREF(grid_type); + if (!py_grid) return NULL; + + py_grid->data = self->grid; + py_grid->weakreflist = NULL; + return (PyObject*)py_grid; +} + +int PyGridLayerAPI::TileLayer_set_grid(PyTileLayerObject* self, PyObject* value, void* closure) { + if (!self->data) { + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return -1; + } + + // Handle None - unlink from current grid + if (value == Py_None || value == NULL) { + if (self->grid) { + self->data->parent_grid = nullptr; + self->grid->removeLayer(self->data); + self->grid.reset(); + } + return 0; + } + + // Validate it's a Grid + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return -1; + + auto* grid_type = PyObject_GetAttrString(mcrfpy_module, "Grid"); + Py_DECREF(mcrfpy_module); + if (!grid_type) return -1; + + if (!PyObject_IsInstance(value, grid_type)) { + Py_DECREF(grid_type); + PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None"); + return -1; + } + Py_DECREF(grid_type); + + PyUIGridObject* py_grid = (PyUIGridObject*)value; + + // Check if already attached to this grid + if (self->grid.get() == py_grid->data.get()) { + return 0; // Nothing to do + } + + // Unlink from old grid if any + if (self->grid) { + self->data->parent_grid = nullptr; + self->grid->removeLayer(self->data); + } + + // Check for protected names + if (!self->data->name.empty() && UIGrid::isProtectedLayerName(self->data->name)) { + PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", self->data->name.c_str()); + self->grid.reset(); + return -1; + } + + // Handle name collision - unlink existing layer with same name + if (!self->data->name.empty()) { + auto existing = py_grid->data->getLayerByName(self->data->name); + if (existing && existing.get() != self->data.get()) { + existing->parent_grid = nullptr; + py_grid->data->removeLayer(existing); + } + } + + // Lazy allocation: resize if layer is (0,0) + if (self->data->grid_x == 0 && self->data->grid_y == 0) { + self->data->resize(py_grid->data->grid_w, py_grid->data->grid_h); + } else if (self->data->grid_x != py_grid->data->grid_w || + self->data->grid_y != py_grid->data->grid_h) { + PyErr_Format(PyExc_ValueError, + "Layer size (%d, %d) does not match Grid size (%d, %d)", + self->data->grid_x, self->data->grid_y, + py_grid->data->grid_w, py_grid->data->grid_h); + self->grid.reset(); + return -1; + } + + // Link to new grid + self->data->parent_grid = py_grid->data.get(); + py_grid->data->layers.push_back(std::static_pointer_cast(self->data)); + py_grid->data->layers_need_sort = true; + self->grid = py_grid->data; + + return 0; +} + PyObject* PyGridLayerAPI::TileLayer_repr(PyTileLayerObject* self) { std::ostringstream ss; if (!self->data) { ss << ""; } else { - ss << "data->name.empty()) { + ss << " '" << self->data->name << "'"; + } + ss << " z_index=" << self->data->z_index << " size=(" << self->data->grid_x << "x" << self->data->grid_y << ")" << " visible=" << (self->data->visible ? "True" : "False") << " texture=" << (self->data->texture ? "set" : "None") << ">"; diff --git a/src/GridLayers.h b/src/GridLayers.h index 45cc10f..49109df 100644 --- a/src/GridLayers.h +++ b/src/GridLayers.h @@ -212,6 +212,9 @@ public: static PyObject* ColorLayer_get_visible(PyColorLayerObject* self, void* closure); static int ColorLayer_set_visible(PyColorLayerObject* self, PyObject* value, void* closure); static PyObject* ColorLayer_get_grid_size(PyColorLayerObject* self, void* closure); + static PyObject* ColorLayer_get_name(PyColorLayerObject* self, void* closure); + static PyObject* ColorLayer_get_grid(PyColorLayerObject* self, void* closure); + static int ColorLayer_set_grid(PyColorLayerObject* self, PyObject* value, void* closure); static PyObject* ColorLayer_repr(PyColorLayerObject* self); // TileLayer methods @@ -229,6 +232,9 @@ public: static PyObject* TileLayer_get_texture(PyTileLayerObject* self, void* closure); static int TileLayer_set_texture(PyTileLayerObject* self, PyObject* value, void* closure); static PyObject* TileLayer_get_grid_size(PyTileLayerObject* self, void* closure); + static PyObject* TileLayer_get_name(PyTileLayerObject* self, void* closure); + static PyObject* TileLayer_get_grid(PyTileLayerObject* self, void* closure); + static int TileLayer_set_grid(PyTileLayerObject* self, PyObject* value, void* closure); static PyObject* TileLayer_repr(PyTileLayerObject* self); // Method and getset arrays @@ -253,21 +259,24 @@ namespace mcrfpydef { }, .tp_repr = (reprfunc)PyGridLayerAPI::ColorLayer_repr, .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("ColorLayer(z_index=-1, grid_size=None)\n\n" + .tp_doc = PyDoc_STR("ColorLayer(z_index=-1, name=None, grid_size=None)\n\n" "A grid layer that stores RGBA colors per cell for background/overlay effects.\n\n" - "ColorLayers are typically created via Grid.add_layer('color', ...) rather than\n" - "instantiated directly. When attached to a Grid, the layer inherits rendering\n" - "parameters and can participate in FOV (field of view) calculations.\n\n" + "ColorLayers can be created standalone and attached to a Grid via add_layer()\n" + "or passed to the Grid constructor's layers parameter. Layers with size (0, 0)\n" + "automatically resize to match the Grid when attached.\n\n" "Args:\n" " z_index (int): Render order relative to entities. Negative values render\n" " below entities (as backgrounds), positive values render above entities\n" " (as overlays). Default: -1 (background)\n" - " grid_size (tuple): Dimensions as (width, height). If None, the layer will\n" - " inherit the parent Grid's dimensions when attached. Default: None\n\n" + " name (str): Layer name for Grid.layer(name) lookup. Default: None\n" + " grid_size (tuple): Dimensions as (width, height). If None or (0, 0), the\n" + " layer will auto-resize when attached to a Grid. Default: None\n\n" "Attributes:\n" " z_index (int): Layer z-order relative to entities (read/write)\n" + " name (str): Layer name for lookup (read-only)\n" " visible (bool): Whether layer is rendered (read/write)\n" - " grid_size (tuple): Layer dimensions as (width, height) (read-only)\n\n" + " grid_size (tuple): Layer dimensions as (width, height) (read-only)\n" + " grid (Grid): Parent Grid or None. Setting manages layer association.\n\n" "Methods:\n" " at(x, y) -> Color: Get the color at cell position (x, y)\n" " set(x, y, color): Set the color at cell position (x, y)\n" @@ -276,11 +285,10 @@ namespace mcrfpydef { " draw_fov(...): Draw FOV-based visibility colors\n" " apply_perspective(entity, ...): Bind layer to entity for automatic FOV updates\n\n" "Example:\n" - " grid = mcrfpy.Grid(grid_size=(20, 15), texture=my_texture,\n" - " pos=(50, 50), size=(640, 480))\n" - " layer = grid.add_layer('color', z_index=-1)\n" - " layer.fill(mcrfpy.Color(40, 40, 40)) # Dark gray background\n" - " layer.set(5, 5, mcrfpy.Color(255, 0, 0, 128)) # Semi-transparent red cell"), + " fog = mcrfpy.ColorLayer(z_index=-1, name='fog')\n" + " grid = mcrfpy.Grid(grid_size=(20, 15), layers=[fog])\n" + " fog.fill(mcrfpy.Color(40, 40, 40)) # Dark gray background\n" + " grid.layer('fog').set(5, 5, mcrfpy.Color(255, 0, 0, 128))"), .tp_methods = PyGridLayerAPI::ColorLayer_methods, .tp_getset = PyGridLayerAPI::ColorLayer_getsetters, .tp_init = (initproc)PyGridLayerAPI::ColorLayer_init, @@ -304,25 +312,28 @@ namespace mcrfpydef { }, .tp_repr = (reprfunc)PyGridLayerAPI::TileLayer_repr, .tp_flags = Py_TPFLAGS_DEFAULT, - .tp_doc = PyDoc_STR("TileLayer(z_index=-1, texture=None, grid_size=None)\n\n" + .tp_doc = PyDoc_STR("TileLayer(z_index=-1, name=None, texture=None, grid_size=None)\n\n" "A grid layer that stores sprite indices per cell for tile-based rendering.\n\n" - "TileLayers are typically created via Grid.add_layer('tile', ...) rather than\n" - "instantiated directly. Each cell stores an integer index into the layer's\n" - "sprite atlas texture. An index of -1 means no tile (transparent/empty).\n\n" + "TileLayers can be created standalone and attached to a Grid via add_layer()\n" + "or passed to the Grid constructor's layers parameter. Layers with size (0, 0)\n" + "automatically resize to match the Grid when attached.\n\n" "Args:\n" " z_index (int): Render order relative to entities. Negative values render\n" " below entities (as backgrounds), positive values render above entities\n" " (as overlays). Default: -1 (background)\n" + " name (str): Layer name for Grid.layer(name) lookup. Default: None\n" " texture (Texture): Sprite atlas containing tile images. The texture's\n" " sprite_size determines individual tile dimensions. Required for\n" " rendering; can be set after creation. Default: None\n" - " grid_size (tuple): Dimensions as (width, height). If None, the layer will\n" - " inherit the parent Grid's dimensions when attached. Default: None\n\n" + " grid_size (tuple): Dimensions as (width, height). If None or (0, 0), the\n" + " layer will auto-resize when attached to a Grid. Default: None\n\n" "Attributes:\n" " z_index (int): Layer z-order relative to entities (read/write)\n" + " name (str): Layer name for lookup (read-only)\n" " visible (bool): Whether layer is rendered (read/write)\n" " texture (Texture): Sprite atlas for tile images (read/write)\n" - " grid_size (tuple): Layer dimensions as (width, height) (read-only)\n\n" + " grid_size (tuple): Layer dimensions as (width, height) (read-only)\n" + " grid (Grid): Parent Grid or None. Setting manages layer association.\n\n" "Methods:\n" " at(x, y) -> int: Get the tile index at cell position (x, y)\n" " set(x, y, index): Set the tile index at cell position (x, y)\n" @@ -332,12 +343,10 @@ namespace mcrfpydef { " -1: No tile (transparent/empty cell)\n" " 0+: Index into the texture's sprite atlas (row-major order)\n\n" "Example:\n" - " grid = mcrfpy.Grid(grid_size=(20, 15), texture=my_texture,\n" - " pos=(50, 50), size=(640, 480))\n" - " layer = grid.add_layer('tile', z_index=1, texture=overlay_texture)\n" - " layer.fill(-1) # Clear layer (all transparent)\n" - " layer.set(5, 5, 42) # Place tile index 42 at position (5, 5)\n" - " layer.fill_rect(0, 0, 20, 1, 10) # Top row filled with tile 10"), + " terrain = mcrfpy.TileLayer(z_index=-2, name='terrain', texture=tileset)\n" + " grid = mcrfpy.Grid(grid_size=(20, 15), layers=[terrain])\n" + " terrain.fill(0) # Fill with tile index 0\n" + " grid.layer('terrain').set(5, 5, 42) # Place tile 42 at (5, 5)"), .tp_methods = PyGridLayerAPI::TileLayer_methods, .tp_getset = PyGridLayerAPI::TileLayer_getsetters, .tp_init = (initproc)PyGridLayerAPI::TileLayer_init, diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 9af5354..068f25d 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -924,51 +924,185 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { self->data->click_register(click_handler); } - // #150 - Handle layers dict - // Default: {"tilesprite": "tile"} when layers not provided - // Empty dict: no rendering layers (entity storage + pathfinding only) + // #150 - Handle layers parameter + // Default: single TileLayer named "tilesprite" when layers not provided + // Empty list/None: no rendering layers (entity storage + pathfinding only) + // List of layer objects: add each layer with lazy allocation if (layers_obj == nullptr) { // Default layer: single TileLayer named "tilesprite" (z_index -1 = below entities) self->data->addTileLayer(-1, texture_ptr, "tilesprite"); } else if (layers_obj != Py_None) { - if (!PyDict_Check(layers_obj)) { - PyErr_SetString(PyExc_TypeError, "layers must be a dict mapping names to types ('color' or 'tile')"); + // Accept any iterable of layer objects + PyObject* iterator = PyObject_GetIter(layers_obj); + if (!iterator) { + PyErr_SetString(PyExc_TypeError, "layers must be an iterable of ColorLayer or TileLayer objects"); return -1; } - PyObject* key; - PyObject* value; - Py_ssize_t pos = 0; - int layer_z = -1; // Start at -1 (below entities), decrement for each layer - - while (PyDict_Next(layers_obj, &pos, &key, &value)) { - if (!PyUnicode_Check(key)) { - PyErr_SetString(PyExc_TypeError, "Layer names must be strings"); - return -1; - } - if (!PyUnicode_Check(value)) { - PyErr_SetString(PyExc_TypeError, "Layer types must be strings ('color' or 'tile')"); - return -1; - } - - const char* layer_name = PyUnicode_AsUTF8(key); - const char* layer_type = PyUnicode_AsUTF8(value); - - // Check for protected names - if (UIGrid::isProtectedLayerName(layer_name)) { - PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer_name); - return -1; - } - - if (strcmp(layer_type, "color") == 0) { - self->data->addColorLayer(layer_z--, layer_name); - } else if (strcmp(layer_type, "tile") == 0) { - self->data->addTileLayer(layer_z--, texture_ptr, layer_name); - } else { - PyErr_Format(PyExc_ValueError, "Unknown layer type '%s' (expected 'color' or 'tile')", layer_type); - return -1; - } + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) { + Py_DECREF(iterator); + return -1; } + + auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); + auto* tile_layer_type = PyObject_GetAttrString(mcrfpy_module, "TileLayer"); + Py_DECREF(mcrfpy_module); + + if (!color_layer_type || !tile_layer_type) { + if (color_layer_type) Py_DECREF(color_layer_type); + if (tile_layer_type) Py_DECREF(tile_layer_type); + Py_DECREF(iterator); + return -1; + } + + PyObject* item; + while ((item = PyIter_Next(iterator)) != NULL) { + std::shared_ptr layer; + + if (PyObject_IsInstance(item, color_layer_type)) { + PyColorLayerObject* py_layer = (PyColorLayerObject*)item; + if (!py_layer->data) { + Py_DECREF(item); + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return -1; + } + + // Check if already attached to another grid + if (py_layer->grid) { + Py_DECREF(item); + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid"); + return -1; + } + + layer = py_layer->data; + + // Check for protected names + if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) { + Py_DECREF(item); + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str()); + return -1; + } + + // Handle name collision + if (!layer->name.empty()) { + auto existing = self->data->getLayerByName(layer->name); + if (existing) { + existing->parent_grid = nullptr; + self->data->removeLayer(existing); + } + } + + // Lazy allocation: resize if layer is (0,0) + if (layer->grid_x == 0 && layer->grid_y == 0) { + layer->resize(self->data->grid_w, self->data->grid_h); + } else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) { + Py_DECREF(item); + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_Format(PyExc_ValueError, + "Layer size (%d, %d) does not match Grid size (%d, %d)", + layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h); + return -1; + } + + // Link to grid + layer->parent_grid = self->data.get(); + self->data->layers.push_back(layer); + py_layer->grid = self->data; + + } else if (PyObject_IsInstance(item, tile_layer_type)) { + PyTileLayerObject* py_layer = (PyTileLayerObject*)item; + if (!py_layer->data) { + Py_DECREF(item); + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return -1; + } + + // Check if already attached to another grid + if (py_layer->grid) { + Py_DECREF(item); + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid"); + return -1; + } + + layer = py_layer->data; + + // Check for protected names + if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) { + Py_DECREF(item); + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str()); + return -1; + } + + // Handle name collision + if (!layer->name.empty()) { + auto existing = self->data->getLayerByName(layer->name); + if (existing) { + existing->parent_grid = nullptr; + self->data->removeLayer(existing); + } + } + + // Lazy allocation: resize if layer is (0,0) + if (layer->grid_x == 0 && layer->grid_y == 0) { + layer->resize(self->data->grid_w, self->data->grid_h); + } else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) { + Py_DECREF(item); + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_Format(PyExc_ValueError, + "Layer size (%d, %d) does not match Grid size (%d, %d)", + layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h); + return -1; + } + + // Link to grid + layer->parent_grid = self->data.get(); + self->data->layers.push_back(layer); + py_layer->grid = self->data; + + } else { + Py_DECREF(item); + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_TypeError, "layers must contain only ColorLayer or TileLayer objects"); + return -1; + } + + Py_DECREF(item); + } + + Py_DECREF(iterator); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + + if (PyErr_Occurred()) { + return -1; + } + + self->data->layers_need_sort = true; } // else: layers_obj is Py_None - explicit empty, no layers created @@ -1456,74 +1590,151 @@ PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* k // Grid.get_dijkstra_map() returns DijkstraMap objects (cached by root) // #147 - Layer system Python API -PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"type", "z_index", "texture", NULL}; - const char* type_str = nullptr; - int z_index = -1; - PyObject* texture_obj = nullptr; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|iO", const_cast(kwlist), - &type_str, &z_index, &texture_obj)) { +PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) { + PyObject* layer_obj; + if (!PyArg_ParseTuple(args, "O", &layer_obj)) { return NULL; } - std::string type(type_str); + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return NULL; - if (type == "color") { - auto layer = self->data->addColorLayer(z_index); + auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); + auto* tile_layer_type = PyObject_GetAttrString(mcrfpy_module, "TileLayer"); + Py_DECREF(mcrfpy_module); - // Create Python ColorLayer object - auto* color_layer_type = (PyTypeObject*)PyObject_GetAttrString( - PyImport_ImportModule("mcrfpy"), "ColorLayer"); - if (!color_layer_type) return NULL; + if (!color_layer_type || !tile_layer_type) { + if (color_layer_type) Py_DECREF(color_layer_type); + if (tile_layer_type) Py_DECREF(tile_layer_type); + return NULL; + } - PyColorLayerObject* py_layer = (PyColorLayerObject*)color_layer_type->tp_alloc(color_layer_type, 0); - Py_DECREF(color_layer_type); - if (!py_layer) return NULL; + std::shared_ptr layer; + PyObject* py_layer_ref = nullptr; - py_layer->data = layer; - py_layer->grid = self->data; - return (PyObject*)py_layer; - - } else if (type == "tile") { - // Parse texture - std::shared_ptr texture; - if (texture_obj && texture_obj != Py_None) { - auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); - if (!mcrfpy_module) return NULL; - - auto* texture_type = PyObject_GetAttrString(mcrfpy_module, "Texture"); - Py_DECREF(mcrfpy_module); - if (!texture_type) return NULL; - - if (!PyObject_IsInstance(texture_obj, texture_type)) { - Py_DECREF(texture_type); - PyErr_SetString(PyExc_TypeError, "texture must be a Texture object"); - return NULL; - } - Py_DECREF(texture_type); - texture = ((PyTextureObject*)texture_obj)->data; + if (PyObject_IsInstance(layer_obj, color_layer_type)) { + PyColorLayerObject* py_layer = (PyColorLayerObject*)layer_obj; + if (!py_layer->data) { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; } - auto layer = self->data->addTileLayer(z_index, texture); + // Check if already attached to another grid + if (py_layer->grid && py_layer->grid.get() != self->data.get()) { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid"); + return NULL; + } - // Create Python TileLayer object - auto* tile_layer_type = (PyTypeObject*)PyObject_GetAttrString( - PyImport_ImportModule("mcrfpy"), "TileLayer"); - if (!tile_layer_type) return NULL; + layer = py_layer->data; + py_layer_ref = layer_obj; - PyTileLayerObject* py_layer = (PyTileLayerObject*)tile_layer_type->tp_alloc(tile_layer_type, 0); - Py_DECREF(tile_layer_type); - if (!py_layer) return NULL; + // Check for protected names + if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str()); + return NULL; + } - py_layer->data = layer; + // Handle name collision - unlink existing layer with same name + if (!layer->name.empty()) { + auto existing = self->data->getLayerByName(layer->name); + if (existing && existing.get() != layer.get()) { + existing->parent_grid = nullptr; + self->data->removeLayer(existing); + } + } + + // Lazy allocation: resize if layer is (0,0) + if (layer->grid_x == 0 && layer->grid_y == 0) { + layer->resize(self->data->grid_w, self->data->grid_h); + } else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_Format(PyExc_ValueError, + "Layer size (%d, %d) does not match Grid size (%d, %d)", + layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h); + return NULL; + } + + // Link to grid + layer->parent_grid = self->data.get(); + self->data->layers.push_back(layer); + self->data->layers_need_sort = true; + py_layer->grid = self->data; + + } else if (PyObject_IsInstance(layer_obj, tile_layer_type)) { + PyTileLayerObject* py_layer = (PyTileLayerObject*)layer_obj; + if (!py_layer->data) { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_RuntimeError, "Layer has no data"); + return NULL; + } + + // Check if already attached to another grid + if (py_layer->grid && py_layer->grid.get() != self->data.get()) { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid"); + return NULL; + } + + layer = py_layer->data; + py_layer_ref = layer_obj; + + // Check for protected names + if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str()); + return NULL; + } + + // Handle name collision - unlink existing layer with same name + if (!layer->name.empty()) { + auto existing = self->data->getLayerByName(layer->name); + if (existing && existing.get() != layer.get()) { + existing->parent_grid = nullptr; + self->data->removeLayer(existing); + } + } + + // Lazy allocation: resize if layer is (0,0) + if (layer->grid_x == 0 && layer->grid_y == 0) { + layer->resize(self->data->grid_w, self->data->grid_h); + } else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_Format(PyExc_ValueError, + "Layer size (%d, %d) does not match Grid size (%d, %d)", + layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h); + return NULL; + } + + // Link to grid + layer->parent_grid = self->data.get(); + self->data->layers.push_back(layer); + self->data->layers_need_sort = true; py_layer->grid = self->data; - return (PyObject*)py_layer; } else { - PyErr_SetString(PyExc_ValueError, "type must be 'color' or 'tile'"); + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_TypeError, "layer must be a ColorLayer or TileLayer"); return NULL; } + + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + + // Return the layer object (incref it since we're returning a reference) + Py_INCREF(py_layer_ref); + return py_layer_ref; } PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) { @@ -1532,6 +1743,22 @@ PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) { return NULL; } + // Check if it's a string (layer name) + if (PyUnicode_Check(layer_obj)) { + const char* name_str = PyUnicode_AsUTF8(layer_obj); + if (!name_str) return NULL; + + auto layer = self->data->getLayerByName(std::string(name_str)); + if (!layer) { + PyErr_Format(PyExc_KeyError, "Layer '%s' not found", name_str); + return NULL; + } + + layer->parent_grid = nullptr; + self->data->removeLayer(layer); + Py_RETURN_NONE; + } + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); if (!mcrfpy_module) return NULL; @@ -1541,7 +1768,11 @@ PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) { Py_DECREF(color_layer_type); Py_DECREF(mcrfpy_module); auto* py_layer = (PyColorLayerObject*)layer_obj; - self->data->removeLayer(py_layer->data); + if (py_layer->data) { + py_layer->data->parent_grid = nullptr; + self->data->removeLayer(py_layer->data); + py_layer->grid.reset(); + } Py_RETURN_NONE; } if (color_layer_type) Py_DECREF(color_layer_type); @@ -1552,25 +1783,29 @@ PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) { Py_DECREF(tile_layer_type); Py_DECREF(mcrfpy_module); auto* py_layer = (PyTileLayerObject*)layer_obj; - self->data->removeLayer(py_layer->data); + if (py_layer->data) { + py_layer->data->parent_grid = nullptr; + self->data->removeLayer(py_layer->data); + py_layer->grid.reset(); + } Py_RETURN_NONE; } if (tile_layer_type) Py_DECREF(tile_layer_type); Py_DECREF(mcrfpy_module); - PyErr_SetString(PyExc_TypeError, "layer must be a ColorLayer or TileLayer"); + PyErr_SetString(PyExc_TypeError, "layer must be a string (layer name), ColorLayer, or TileLayer"); return NULL; } PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) { self->data->sortLayers(); - PyObject* list = PyList_New(self->data->layers.size()); - if (!list) return NULL; + PyObject* tuple = PyTuple_New(self->data->layers.size()); + if (!tuple) return NULL; auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); if (!mcrfpy_module) { - Py_DECREF(list); + Py_DECREF(tuple); return NULL; } @@ -1581,7 +1816,7 @@ PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) { if (!color_layer_type || !tile_layer_type) { if (color_layer_type) Py_DECREF(color_layer_type); if (tile_layer_type) Py_DECREF(tile_layer_type); - Py_DECREF(list); + Py_DECREF(tuple); return NULL; } @@ -1608,58 +1843,57 @@ PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) { if (!py_layer) { Py_DECREF(color_layer_type); Py_DECREF(tile_layer_type); - Py_DECREF(list); + Py_DECREF(tuple); return NULL; } - PyList_SET_ITEM(list, i, py_layer); // Steals reference + PyTuple_SET_ITEM(tuple, i, py_layer); // Steals reference } Py_DECREF(color_layer_type); Py_DECREF(tile_layer_type); - return list; + return tuple; } PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) { - int z_index; - if (!PyArg_ParseTuple(args, "i", &z_index)) { + const char* name_str; + if (!PyArg_ParseTuple(args, "s", &name_str)) { return NULL; } - for (auto& layer : self->data->layers) { - if (layer->z_index == z_index) { - auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); - if (!mcrfpy_module) return NULL; - - if (layer->type == GridLayerType::Color) { - auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); - Py_DECREF(mcrfpy_module); - if (!type) return NULL; - - PyColorLayerObject* obj = (PyColorLayerObject*)type->tp_alloc(type, 0); - Py_DECREF(type); - if (!obj) return NULL; - - obj->data = std::static_pointer_cast(layer); - obj->grid = self->data; - return (PyObject*)obj; - } else { - auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "TileLayer"); - Py_DECREF(mcrfpy_module); - if (!type) return NULL; - - PyTileLayerObject* obj = (PyTileLayerObject*)type->tp_alloc(type, 0); - Py_DECREF(type); - if (!obj) return NULL; - - obj->data = std::static_pointer_cast(layer); - obj->grid = self->data; - return (PyObject*)obj; - } - } + auto layer = self->data->getLayerByName(std::string(name_str)); + if (!layer) { + Py_RETURN_NONE; } - Py_RETURN_NONE; + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return NULL; + + if (layer->type == GridLayerType::Color) { + auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); + Py_DECREF(mcrfpy_module); + if (!type) return NULL; + + PyColorLayerObject* obj = (PyColorLayerObject*)type->tp_alloc(type, 0); + Py_DECREF(type); + if (!obj) return NULL; + + obj->data = std::static_pointer_cast(layer); + obj->grid = self->data; + return (PyObject*)obj; + } else { + auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "TileLayer"); + Py_DECREF(mcrfpy_module); + if (!type) return NULL; + + PyTileLayerObject* obj = (PyTileLayerObject*)type->tp_alloc(type, 0); + Py_DECREF(type); + if (!obj) return NULL; + + obj->data = std::static_pointer_cast(layer); + obj->grid = self->data; + return (PyObject*)obj; + } } // #115 - Spatial hash query for entities in radius @@ -2061,12 +2295,12 @@ PyMethodDef UIGrid::methods[] = { "Clear all cached Dijkstra maps.\n\n" "Call this after modifying grid cell walkability to ensure pathfinding\n" "uses updated walkability data."}, - {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS, - "add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer"}, + {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS, + "add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer"}, {"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS, - "remove_layer(layer: ColorLayer | TileLayer) -> None"}, + "remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None"}, {"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS, - "layer(z_index: int) -> ColorLayer | TileLayer | None"}, + "layer(name: str) -> ColorLayer | TileLayer | None"}, {"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS, "entities_in_radius(pos: tuple|Vector, radius: float) -> list[Entity]\n\n" "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" @@ -2166,27 +2400,34 @@ PyMethodDef UIGrid_all_methods[] = { "Clear all cached Dijkstra maps.\n\n" "Call this after modifying grid cell walkability to ensure pathfinding\n" "uses updated walkability data."}, - {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS, - "add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer\n\n" - "Add a new layer to the grid.\n\n" + {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS, + "add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer\n\n" + "Add a layer to the grid.\n\n" "Args:\n" - " type: Layer type ('color' or 'tile')\n" - " z_index: Render order. Negative = below entities, >= 0 = above entities. Default: -1\n" - " texture: Texture for tile layers. Required for 'tile' type.\n\n" + " layer: A ColorLayer or TileLayer object. Layers with size (0, 0) are\n" + " automatically resized to match the grid. Named layers replace\n" + " any existing layer with the same name.\n\n" "Returns:\n" - " The created ColorLayer or TileLayer object."}, + " The added layer object.\n\n" + "Raises:\n" + " ValueError: If layer is already attached to another grid, or if\n" + " layer size doesn't match grid (and isn't (0,0)).\n" + " TypeError: If argument is not a ColorLayer or TileLayer."}, {"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS, - "remove_layer(layer: ColorLayer | TileLayer) -> None\n\n" + "remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None\n\n" "Remove a layer from the grid.\n\n" "Args:\n" - " layer: The layer to remove."}, + " name_or_layer: Either a layer name (str) or the layer object itself.\n\n" + "Raises:\n" + " KeyError: If name is provided but no layer with that name exists.\n" + " TypeError: If argument is not a string, ColorLayer, or TileLayer."}, {"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS, - "layer(z_index: int) -> ColorLayer | TileLayer | None\n\n" - "Get a layer by its z_index.\n\n" + "layer(name: str) -> ColorLayer | TileLayer | None\n\n" + "Get a layer by its name.\n\n" "Args:\n" - " z_index: The z_index of the layer to find.\n\n" + " name: The name of the layer to find.\n\n" "Returns:\n" - " The layer with the specified z_index, or None if not found."}, + " The layer with the specified name, or None if not found."}, {"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS, "entities_in_radius(pos: tuple|Vector, radius: float) -> list[Entity]\n\n" "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" diff --git a/src/UIGrid.h b/src/UIGrid.h index 7b8191c..318e71c 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -243,7 +243,7 @@ public: static PyObject* get_hovered_cell(PyUIGridObject* self, void* closure); // #147 - Layer system Python API - static PyObject* py_add_layer(PyUIGridObject* self, PyObject* args, PyObject* kwds); + static PyObject* py_add_layer(PyUIGridObject* self, PyObject* args); static PyObject* py_remove_layer(PyUIGridObject* self, PyObject* args); static PyObject* get_layers(PyUIGridObject* self, void* closure); static PyObject* py_layer(PyUIGridObject* self, PyObject* args); diff --git a/tests/unit/grid_layer_api_test.py b/tests/unit/grid_layer_api_test.py new file mode 100644 index 0000000..9a1d378 --- /dev/null +++ b/tests/unit/grid_layer_api_test.py @@ -0,0 +1,275 @@ +""" +Unit tests for the modernized Grid Layer API. + +Tests: +1. Layer name in constructor +2. Lazy allocation (size (0,0) auto-resizes) +3. layer.grid property (getter and setter) +4. grid.layer(name) lookup +5. grid.layers returns tuple +6. add_layer accepts layer objects +7. remove_layer by name and by layer object +8. Name collision handling +""" + +import mcrfpy +import sys + +def test_layer_name_in_constructor(): + """Test that layers can be created with names.""" + print("Test 1: Layer name in constructor...") + + color_layer = mcrfpy.ColorLayer(name='fog', z_index=-1) + assert color_layer.name == 'fog', f"Expected 'fog', got {color_layer.name}" + + tile_layer = mcrfpy.TileLayer(name='terrain', z_index=-2) + assert tile_layer.name == 'terrain', f"Expected 'terrain', got {tile_layer.name}" + + # Name should appear in repr + assert 'fog' in repr(color_layer), f"Name not in repr: {repr(color_layer)}" + assert 'terrain' in repr(tile_layer), f"Name not in repr: {repr(tile_layer)}" + + # Layers without name should work too + unnamed = mcrfpy.ColorLayer(z_index=0) + assert unnamed.name == '', f"Expected empty string, got {unnamed.name}" + + print(" PASS") + +def test_lazy_allocation(): + """Test that layers with size (0,0) auto-resize when attached to grid.""" + print("Test 2: Lazy allocation...") + + layer = mcrfpy.ColorLayer(name='test') + assert layer.grid_size == (0, 0), f"Expected (0,0), got {layer.grid_size}" + + grid = mcrfpy.Grid(grid_size=(10, 8), layers=[layer]) + assert layer.grid_size == (10, 8), f"Expected (10,8), got {layer.grid_size}" + + print(" PASS") + +def test_layer_grid_property(): + """Test that layer.grid property works for getting and setting.""" + print("Test 3: layer.grid property...") + + layer = mcrfpy.ColorLayer(name='overlay', z_index=0) + assert layer.grid is None, f"Expected None, got {layer.grid}" + + grid = mcrfpy.Grid(grid_size=(5, 5), layers=[]) + layer.grid = grid + + assert layer.grid is not None, "layer.grid should not be None after assignment" + assert len(grid.layers) == 1, f"Expected 1 layer, got {len(grid.layers)}" + assert layer.grid_size == (5, 5), f"Expected (5,5), got {layer.grid_size}" + + # Unlink by setting to None + layer.grid = None + assert layer.grid is None, "layer.grid should be None after unsetting" + assert len(grid.layers) == 0, f"Expected 0 layers, got {len(grid.layers)}" + + print(" PASS") + +def test_grid_layer_name_lookup(): + """Test that grid.layer(name) finds layers by name.""" + print("Test 4: grid.layer(name) lookup...") + + fog = mcrfpy.ColorLayer(name='fog', z_index=-1) + terrain = mcrfpy.TileLayer(name='terrain', z_index=-2) + grid = mcrfpy.Grid(grid_size=(5, 5), layers=[fog, terrain]) + + retrieved_fog = grid.layer('fog') + assert retrieved_fog is not None, "Should find 'fog' layer" + assert retrieved_fog.name == 'fog', f"Expected 'fog', got {retrieved_fog.name}" + + retrieved_terrain = grid.layer('terrain') + assert retrieved_terrain is not None, "Should find 'terrain' layer" + assert retrieved_terrain.name == 'terrain', f"Expected 'terrain', got {retrieved_terrain.name}" + + # Non-existent name should return None + result = grid.layer('nonexistent') + assert result is None, f"Expected None for nonexistent, got {result}" + + print(" PASS") + +def test_layers_returns_tuple(): + """Test that grid.layers returns a tuple (immutable).""" + print("Test 5: grid.layers returns tuple...") + + fog = mcrfpy.ColorLayer(name='fog') + grid = mcrfpy.Grid(grid_size=(5, 5), layers=[fog]) + + layers = grid.layers + assert isinstance(layers, tuple), f"Expected tuple, got {type(layers)}" + + print(" PASS") + +def test_add_layer_accepts_objects(): + """Test that add_layer accepts ColorLayer and TileLayer objects.""" + print("Test 6: add_layer accepts objects...") + + grid = mcrfpy.Grid(grid_size=(5, 5), layers=[]) + assert len(grid.layers) == 0, f"Expected 0 layers, got {len(grid.layers)}" + + new_layer = mcrfpy.TileLayer(name='overlay', z_index=1) + returned = grid.add_layer(new_layer) + + assert len(grid.layers) == 1, f"Expected 1 layer, got {len(grid.layers)}" + assert new_layer.grid is not None, "Layer should be attached to grid" + assert returned is new_layer, "Should return the same layer object" + + # Add another layer + color = mcrfpy.ColorLayer(name='highlights', z_index=2) + grid.add_layer(color) + assert len(grid.layers) == 2, f"Expected 2 layers, got {len(grid.layers)}" + + print(" PASS") + +def test_remove_layer_by_name(): + """Test that remove_layer accepts layer name as string.""" + print("Test 7a: remove_layer by name...") + + fog = mcrfpy.ColorLayer(name='fog') + terrain = mcrfpy.TileLayer(name='terrain') + grid = mcrfpy.Grid(grid_size=(5, 5), layers=[fog, terrain]) + + assert len(grid.layers) == 2 + + grid.remove_layer('fog') + assert len(grid.layers) == 1, f"Expected 1 layer, got {len(grid.layers)}" + assert grid.layer('fog') is None, "fog should be removed" + assert grid.layer('terrain') is not None, "terrain should remain" + + # Removing non-existent should raise KeyError + try: + grid.remove_layer('nonexistent') + assert False, "Should raise KeyError for nonexistent layer" + except KeyError: + pass # Expected + + print(" PASS") + +def test_remove_layer_by_object(): + """Test that remove_layer accepts layer object.""" + print("Test 7b: remove_layer by object...") + + fog = mcrfpy.ColorLayer(name='fog') + terrain = mcrfpy.TileLayer(name='terrain') + grid = mcrfpy.Grid(grid_size=(5, 5), layers=[fog, terrain]) + + grid.remove_layer(terrain) + assert len(grid.layers) == 1, f"Expected 1 layer, got {len(grid.layers)}" + assert terrain.grid is None, "Removed layer should have grid=None" + + print(" PASS") + +def test_name_collision_replaces(): + """Test that adding a layer with existing name replaces the old layer.""" + print("Test 8: Name collision replaces old layer...") + + old = mcrfpy.ColorLayer(name='fog') + grid = mcrfpy.Grid(grid_size=(5, 5), layers=[old]) + + assert len(grid.layers) == 1 + assert old.grid is not None + + # Add new layer with same name + new = mcrfpy.ColorLayer(name='fog') + grid.add_layer(new) + + assert len(grid.layers) == 1, f"Expected 1 layer after replacement, got {len(grid.layers)}" + assert old.grid is None, "Old layer should be unlinked" + assert new.grid is not None, "New layer should be linked" + + # Verify it's the new one + retrieved = grid.layer('fog') + # Both have same name, but we can check the new one is attached + assert retrieved is not None + + print(" PASS") + +def test_size_validation(): + """Test that mismatched sizes raise errors.""" + print("Test 9: Size validation...") + + # Layer with specific size that doesn't match grid + layer = mcrfpy.ColorLayer(name='test', grid_size=(10, 10)) + grid = mcrfpy.Grid(grid_size=(5, 5), layers=[]) + + try: + grid.add_layer(layer) + assert False, "Should raise ValueError for size mismatch" + except ValueError as e: + assert "size" in str(e).lower(), f"Error should mention size: {e}" + + print(" PASS") + +def test_protected_names(): + """Test that protected names (walkable, transparent) are rejected.""" + print("Test 10: Protected names...") + + layer = mcrfpy.ColorLayer(name='walkable') + grid = mcrfpy.Grid(grid_size=(5, 5), layers=[]) + + try: + grid.add_layer(layer) + assert False, "Should raise ValueError for protected name 'walkable'" + except ValueError as e: + assert "reserved" in str(e).lower() or "walkable" in str(e).lower(), f"Error: {e}" + + layer2 = mcrfpy.ColorLayer(name='transparent') + try: + grid.add_layer(layer2) + assert False, "Should raise ValueError for protected name 'transparent'" + except ValueError as e: + assert "reserved" in str(e).lower() or "transparent" in str(e).lower(), f"Error: {e}" + + print(" PASS") + +def test_already_attached_error(): + """Test that attaching a layer to two grids raises error.""" + print("Test 11: Already attached error...") + + layer = mcrfpy.ColorLayer(name='shared') + grid1 = mcrfpy.Grid(grid_size=(5, 5), layers=[layer]) + grid2 = mcrfpy.Grid(grid_size=(5, 5), layers=[]) + + try: + grid2.add_layer(layer) + assert False, "Should raise ValueError for already attached layer" + except ValueError as e: + assert "attached" in str(e).lower() or "another" in str(e).lower(), f"Error: {e}" + + print(" PASS") + +def run_all_tests(): + """Run all tests.""" + print("Running Grid Layer API tests...\n") + + test_layer_name_in_constructor() + test_lazy_allocation() + test_layer_grid_property() + test_grid_layer_name_lookup() + test_layers_returns_tuple() + test_add_layer_accepts_objects() + test_remove_layer_by_name() + test_remove_layer_by_object() + test_name_collision_replaces() + test_size_validation() + test_protected_names() + test_already_attached_error() + + print("\n" + "="*50) + print("All tests PASSED!") + print("="*50) + +if __name__ == "__main__": + try: + run_all_tests() + sys.exit(0) + except AssertionError as e: + print(f"\nFAIL: {e}") + sys.exit(1) + except Exception as e: + print(f"\nERROR: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + sys.exit(1)