Compare commits
2 commits
a4b1ab7d68
...
6caf3dcd05
| Author | SHA1 | Date | |
|---|---|---|---|
| 6caf3dcd05 | |||
| 8699bba9e6 |
5 changed files with 2027 additions and 0 deletions
|
|
@ -22,6 +22,7 @@
|
|||
#include "PyMouse.h"
|
||||
#include "UIGridPathfinding.h" // AStarPath and DijkstraMap types
|
||||
#include "PyHeightMap.h" // Procedural generation heightmap (#193)
|
||||
#include "PyBSP.h" // Procedural generation BSP (#202-206)
|
||||
#include "McRogueFaceVersion.h"
|
||||
#include "GameEngine.h"
|
||||
#include "ImGuiConsole.h"
|
||||
|
|
@ -418,6 +419,7 @@ PyObject* PyInit_mcrfpy()
|
|||
|
||||
/*procedural generation (#192)*/
|
||||
&mcrfpydef::PyHeightMapType,
|
||||
&mcrfpydef::PyBSPType,
|
||||
|
||||
nullptr};
|
||||
|
||||
|
|
@ -434,6 +436,10 @@ PyObject* PyInit_mcrfpy()
|
|||
/*pathfinding iterator - returned by AStarPath.__iter__() but not directly instantiable*/
|
||||
&mcrfpydef::PyAStarPathIterType,
|
||||
|
||||
/*BSP internal types - returned by BSP methods but not directly instantiable*/
|
||||
&mcrfpydef::PyBSPNodeType,
|
||||
&mcrfpydef::PyBSPIterType,
|
||||
|
||||
nullptr};
|
||||
|
||||
// Set up PyWindowType methods and getsetters before PyType_Ready
|
||||
|
|
@ -448,6 +454,12 @@ PyObject* PyInit_mcrfpy()
|
|||
mcrfpydef::PyHeightMapType.tp_methods = PyHeightMap::methods;
|
||||
mcrfpydef::PyHeightMapType.tp_getset = PyHeightMap::getsetters;
|
||||
|
||||
// Set up PyBSPType and BSPNode methods and getsetters (#202-206)
|
||||
mcrfpydef::PyBSPType.tp_methods = PyBSP::methods;
|
||||
mcrfpydef::PyBSPType.tp_getset = PyBSP::getsetters;
|
||||
mcrfpydef::PyBSPNodeType.tp_methods = PyBSPNode::methods;
|
||||
mcrfpydef::PyBSPNodeType.tp_getset = PyBSPNode::getsetters;
|
||||
|
||||
// Set up weakref support for all types that need it
|
||||
PyTimerType.tp_weaklistoffset = offsetof(PyTimerObject, weakreflist);
|
||||
PyUIFrameType.tp_weaklistoffset = offsetof(PyUIFrameObject, weakreflist);
|
||||
|
|
@ -555,6 +567,13 @@ PyObject* PyInit_mcrfpy()
|
|||
PyErr_Clear();
|
||||
}
|
||||
|
||||
// Add Traversal enum class for BSP traversal (uses Python's IntEnum)
|
||||
PyObject* traversal_class = PyTraversal::create_enum_class(m);
|
||||
if (!traversal_class) {
|
||||
// If enum creation fails, continue without it (non-fatal)
|
||||
PyErr_Clear();
|
||||
}
|
||||
|
||||
// Add Key enum class for keyboard input
|
||||
PyObject* key_class = PyKey::create_enum_class(m);
|
||||
if (!key_class) {
|
||||
|
|
|
|||
1169
src/PyBSP.cpp
Normal file
1169
src/PyBSP.cpp
Normal file
File diff suppressed because it is too large
Load diff
240
src/PyBSP.h
Normal file
240
src/PyBSP.h
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
#pragma once
|
||||
#include "Common.h"
|
||||
#include "Python.h"
|
||||
#include <libtcod.h>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
// Forward declarations
|
||||
class PyBSP;
|
||||
class PyBSPNode;
|
||||
|
||||
// Maximum recursion depth to prevent memory exhaustion
|
||||
// 2^16 = 65536 potential leaf nodes, which is already excessive
|
||||
constexpr int BSP_MAX_DEPTH = 16;
|
||||
|
||||
// Python object structure for BSP tree (root owner)
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
TCOD_bsp_t* root; // libtcod BSP root (owned, will be deleted)
|
||||
int orig_x, orig_y; // Original bounds for clear()
|
||||
int orig_w, orig_h;
|
||||
uint64_t generation; // Incremented on structural changes (clear, split)
|
||||
} PyBSPObject;
|
||||
|
||||
// Python object structure for BSPNode (lightweight reference)
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
TCOD_bsp_t* node; // libtcod BSP node (NOT owned)
|
||||
PyObject* bsp_owner; // Reference to PyBSPObject to prevent dangling
|
||||
uint64_t generation; // Generation at time of creation (for validity check)
|
||||
} PyBSPNodeObject;
|
||||
|
||||
// BSP iterator for traverse()
|
||||
typedef struct {
|
||||
PyObject_HEAD
|
||||
std::vector<TCOD_bsp_t*>* nodes; // Pre-collected nodes
|
||||
size_t index;
|
||||
PyObject* bsp_owner; // Reference to PyBSPObject
|
||||
uint64_t generation; // Generation at iterator creation
|
||||
} PyBSPIterObject;
|
||||
|
||||
class PyBSP
|
||||
{
|
||||
public:
|
||||
// Python type interface
|
||||
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
|
||||
static int init(PyBSPObject* self, PyObject* args, PyObject* kwds);
|
||||
static void dealloc(PyBSPObject* self);
|
||||
static PyObject* repr(PyObject* obj);
|
||||
|
||||
// Properties
|
||||
static PyObject* get_bounds(PyBSPObject* self, void* closure);
|
||||
static PyObject* get_pos(PyBSPObject* self, void* closure);
|
||||
static PyObject* get_size(PyBSPObject* self, void* closure);
|
||||
static PyObject* get_root(PyBSPObject* self, void* closure);
|
||||
|
||||
// Splitting methods (#202)
|
||||
static PyObject* split_once(PyBSPObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* split_recursive(PyBSPObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* clear(PyBSPObject* self, PyObject* Py_UNUSED(args));
|
||||
|
||||
// Iteration methods (#204)
|
||||
static PyObject* leaves(PyBSPObject* self, PyObject* Py_UNUSED(args));
|
||||
static PyObject* traverse(PyBSPObject* self, PyObject* args, PyObject* kwds);
|
||||
|
||||
// Query methods (#205)
|
||||
static PyObject* find(PyBSPObject* self, PyObject* args, PyObject* kwds);
|
||||
|
||||
// HeightMap conversion (#206)
|
||||
static PyObject* to_heightmap(PyBSPObject* self, PyObject* args, PyObject* kwds);
|
||||
|
||||
// Sequence protocol
|
||||
static Py_ssize_t len(PyBSPObject* self);
|
||||
static PyObject* iter(PyBSPObject* self);
|
||||
|
||||
// Method and property definitions
|
||||
static PyMethodDef methods[];
|
||||
static PyGetSetDef getsetters[];
|
||||
static PySequenceMethods sequence_methods;
|
||||
};
|
||||
|
||||
class PyBSPNode
|
||||
{
|
||||
public:
|
||||
// Python type interface
|
||||
static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds);
|
||||
static int init(PyBSPNodeObject* self, PyObject* args, PyObject* kwds);
|
||||
static void dealloc(PyBSPNodeObject* self);
|
||||
static PyObject* repr(PyObject* obj);
|
||||
|
||||
// Comparison
|
||||
static PyObject* richcompare(PyObject* self, PyObject* other, int op);
|
||||
|
||||
// Properties (#203)
|
||||
static PyObject* get_bounds(PyBSPNodeObject* self, void* closure);
|
||||
static PyObject* get_pos(PyBSPNodeObject* self, void* closure);
|
||||
static PyObject* get_size(PyBSPNodeObject* self, void* closure);
|
||||
static PyObject* get_level(PyBSPNodeObject* self, void* closure);
|
||||
static PyObject* get_is_leaf(PyBSPNodeObject* self, void* closure);
|
||||
static PyObject* get_split_horizontal(PyBSPNodeObject* self, void* closure);
|
||||
static PyObject* get_split_position(PyBSPNodeObject* self, void* closure);
|
||||
|
||||
// Navigation properties (#203)
|
||||
static PyObject* get_left(PyBSPNodeObject* self, void* closure);
|
||||
static PyObject* get_right(PyBSPNodeObject* self, void* closure);
|
||||
static PyObject* get_parent(PyBSPNodeObject* self, void* closure);
|
||||
static PyObject* get_sibling(PyBSPNodeObject* self, void* closure);
|
||||
|
||||
// Methods (#203)
|
||||
static PyObject* contains(PyBSPNodeObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* center(PyBSPNodeObject* self, PyObject* Py_UNUSED(args));
|
||||
|
||||
// Helper to create a BSPNode from a TCOD_bsp_t*
|
||||
static PyObject* create(TCOD_bsp_t* node, PyObject* bsp_owner);
|
||||
|
||||
// Validity check - returns false and sets error if node is stale
|
||||
static bool checkValid(PyBSPNodeObject* self);
|
||||
|
||||
// Method and property definitions
|
||||
static PyMethodDef methods[];
|
||||
static PyGetSetDef getsetters[];
|
||||
};
|
||||
|
||||
// BSP Iterator class
|
||||
class PyBSPIter
|
||||
{
|
||||
public:
|
||||
static void dealloc(PyBSPIterObject* self);
|
||||
static PyObject* iter(PyObject* self);
|
||||
static PyObject* next(PyBSPIterObject* self);
|
||||
static int init(PyBSPIterObject* self, PyObject* args, PyObject* kwds);
|
||||
static PyObject* repr(PyObject* obj);
|
||||
};
|
||||
|
||||
// Traversal enum creation
|
||||
class PyTraversal
|
||||
{
|
||||
public:
|
||||
static PyObject* traversal_enum_class;
|
||||
static PyObject* create_enum_class(PyObject* module);
|
||||
static int from_arg(PyObject* arg, int* out_order);
|
||||
// Cleanup for module finalization (optional)
|
||||
static void cleanup();
|
||||
};
|
||||
|
||||
namespace mcrfpydef {
|
||||
// BSP - user-facing, exported
|
||||
inline PyTypeObject PyBSPType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
.tp_name = "mcrfpy.BSP",
|
||||
.tp_basicsize = sizeof(PyBSPObject),
|
||||
.tp_itemsize = 0,
|
||||
.tp_dealloc = (destructor)PyBSP::dealloc,
|
||||
.tp_repr = PyBSP::repr,
|
||||
.tp_as_sequence = &PyBSP::sequence_methods,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT,
|
||||
.tp_doc = PyDoc_STR(
|
||||
"BSP(pos: tuple[int, int], size: tuple[int, int])\n\n"
|
||||
"Binary Space Partitioning tree for procedural dungeon generation.\n\n"
|
||||
"BSP recursively divides a rectangular region into smaller sub-regions, "
|
||||
"creating a tree structure perfect for generating dungeon rooms and corridors.\n\n"
|
||||
"Args:\n"
|
||||
" pos: (x, y) - Top-left position of the root region.\n"
|
||||
" size: (w, h) - Width and height of the root region.\n\n"
|
||||
"Properties:\n"
|
||||
" pos (tuple[int, int]): Read-only. Top-left position (x, y).\n"
|
||||
" size (tuple[int, int]): Read-only. Dimensions (width, height).\n"
|
||||
" bounds ((pos), (size)): Read-only. Combined position and size.\n"
|
||||
" root (BSPNode): Read-only. Reference to the root node.\n\n"
|
||||
"Iteration:\n"
|
||||
" for leaf in bsp: # Iterates over leaf nodes (rooms)\n"
|
||||
" len(bsp) # Returns number of leaf nodes\n\n"
|
||||
"Example:\n"
|
||||
" bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 50))\n"
|
||||
" bsp.split_recursive(depth=4, min_size=(8, 8))\n"
|
||||
" for leaf in bsp:\n"
|
||||
" print(f'Room at {leaf.pos}, size {leaf.size}')\n"
|
||||
),
|
||||
.tp_iter = (getiterfunc)PyBSP::iter,
|
||||
.tp_methods = nullptr, // Set in McRFPy_API.cpp
|
||||
.tp_getset = nullptr, // Set in McRFPy_API.cpp
|
||||
.tp_init = (initproc)PyBSP::init,
|
||||
.tp_new = PyBSP::pynew,
|
||||
};
|
||||
|
||||
// BSPNode - internal type (returned by BSP methods)
|
||||
inline PyTypeObject PyBSPNodeType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
.tp_name = "mcrfpy.BSPNode",
|
||||
.tp_basicsize = sizeof(PyBSPNodeObject),
|
||||
.tp_itemsize = 0,
|
||||
.tp_dealloc = (destructor)PyBSPNode::dealloc,
|
||||
.tp_repr = PyBSPNode::repr,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT,
|
||||
.tp_doc = PyDoc_STR(
|
||||
"BSPNode - Lightweight reference to a node in a BSP tree.\n\n"
|
||||
"BSPNode provides read-only access to node properties and navigation.\n"
|
||||
"Nodes are created by BSP methods, not directly instantiated.\n\n"
|
||||
"WARNING: BSPNode references become invalid after BSP.clear() or\n"
|
||||
"BSP.split_recursive(). Accessing properties of an invalid node\n"
|
||||
"raises RuntimeError.\n\n"
|
||||
"Properties:\n"
|
||||
" pos (tuple[int, int]): Top-left position (x, y).\n"
|
||||
" size (tuple[int, int]): Dimensions (width, height).\n"
|
||||
" bounds ((pos), (size)): Combined position and size.\n"
|
||||
" level (int): Depth in tree (0 for root).\n"
|
||||
" is_leaf (bool): True if this node has no children.\n"
|
||||
" split_horizontal (bool | None): Split orientation, None if leaf.\n"
|
||||
" split_position (int | None): Split coordinate, None if leaf.\n"
|
||||
" left (BSPNode | None): Left child, or None if leaf.\n"
|
||||
" right (BSPNode | None): Right child, or None if leaf.\n"
|
||||
" parent (BSPNode | None): Parent node, or None if root.\n"
|
||||
" sibling (BSPNode | None): Other child of parent, or None.\n"
|
||||
),
|
||||
.tp_richcompare = PyBSPNode::richcompare,
|
||||
.tp_methods = nullptr, // Set in McRFPy_API.cpp
|
||||
.tp_getset = nullptr, // Set in McRFPy_API.cpp
|
||||
.tp_init = (initproc)PyBSPNode::init,
|
||||
.tp_new = PyBSPNode::pynew,
|
||||
};
|
||||
|
||||
// BSP Iterator - internal type
|
||||
inline PyTypeObject PyBSPIterType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
.tp_name = "mcrfpy.BSPIter",
|
||||
.tp_basicsize = sizeof(PyBSPIterObject),
|
||||
.tp_itemsize = 0,
|
||||
.tp_dealloc = (destructor)PyBSPIter::dealloc,
|
||||
.tp_repr = PyBSPIter::repr,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT,
|
||||
.tp_doc = PyDoc_STR("Iterator for BSP tree traversal."),
|
||||
.tp_iter = PyBSPIter::iter,
|
||||
.tp_iternext = (iternextfunc)PyBSPIter::next,
|
||||
.tp_init = (initproc)PyBSPIter::init,
|
||||
.tp_new = [](PyTypeObject* type, PyObject* args, PyObject* kwds) -> PyObject* {
|
||||
PyErr_SetString(PyExc_TypeError, "BSPIter cannot be instantiated directly");
|
||||
return NULL;
|
||||
},
|
||||
};
|
||||
}
|
||||
28
tests/unit/bsp_simple_test.py
Normal file
28
tests/unit/bsp_simple_test.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"""Simple BSP test to identify crash."""
|
||||
import sys
|
||||
import mcrfpy
|
||||
|
||||
print("Step 1: Import complete")
|
||||
|
||||
print("Step 2: Creating BSP...")
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 80))
|
||||
print("Step 2: BSP created:", bsp)
|
||||
|
||||
print("Step 3: Getting bounds...")
|
||||
bounds = bsp.bounds
|
||||
print("Step 3: Bounds:", bounds)
|
||||
|
||||
print("Step 4: Getting root...")
|
||||
root = bsp.root
|
||||
print("Step 4: Root:", root)
|
||||
|
||||
print("Step 5: Split once...")
|
||||
bsp.split_once(horizontal=True, position=40)
|
||||
print("Step 5: Split complete")
|
||||
|
||||
print("Step 6: Get leaves...")
|
||||
leaves = list(bsp.leaves())
|
||||
print("Step 6: Leaves count:", len(leaves))
|
||||
|
||||
print("PASS")
|
||||
sys.exit(0)
|
||||
571
tests/unit/bsp_test.py
Normal file
571
tests/unit/bsp_test.py
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
"""Unit tests for BSP (Binary Space Partitioning) procedural generation.
|
||||
|
||||
Tests for issues #202-#206:
|
||||
- #202: BSP core class with splitting
|
||||
- #203: BSPNode lightweight node reference
|
||||
- #204: BSP iteration (leaves, traverse) with Traversal enum
|
||||
- #205: BSP query methods (find)
|
||||
- #206: BSP.to_heightmap (returns HeightMap; BSPMap subclass deferred)
|
||||
"""
|
||||
import sys
|
||||
import mcrfpy
|
||||
|
||||
def test_bsp_construction():
|
||||
"""Test BSP construction with pos/size."""
|
||||
print("Testing BSP construction...")
|
||||
|
||||
# Basic construction with pos/size kwargs
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 80))
|
||||
assert bsp is not None, "BSP should be created"
|
||||
|
||||
# Check pos and size properties
|
||||
assert bsp.pos == (0, 0), f"pos should be (0, 0), got {bsp.pos}"
|
||||
assert bsp.size == (100, 80), f"size should be (100, 80), got {bsp.size}"
|
||||
|
||||
# Check bounds property (combines pos and size)
|
||||
bounds = bsp.bounds
|
||||
assert bounds == ((0, 0), (100, 80)), f"Bounds should be ((0, 0), (100, 80)), got {bounds}"
|
||||
|
||||
# Check root property
|
||||
root = bsp.root
|
||||
assert root is not None, "Root should not be None"
|
||||
assert root.bounds == ((0, 0), (100, 80)), f"Root bounds mismatch"
|
||||
|
||||
# Construction with offset
|
||||
bsp2 = mcrfpy.BSP(pos=(10, 20), size=(50, 40))
|
||||
assert bsp2.bounds == ((10, 20), (50, 40)), "Offset bounds not preserved"
|
||||
|
||||
print(" BSP construction: PASS")
|
||||
|
||||
def test_bsp_split_once():
|
||||
"""Test single split operation (#202)."""
|
||||
print("Testing BSP split_once...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 80))
|
||||
|
||||
# Before split, root should be a leaf
|
||||
assert bsp.root.is_leaf, "Root should be leaf before split"
|
||||
|
||||
# Horizontal split at y=40
|
||||
result = bsp.split_once(horizontal=True, position=40)
|
||||
assert result is bsp, "split_once should return self for chaining"
|
||||
|
||||
# After split, root should not be a leaf
|
||||
root = bsp.root
|
||||
assert not root.is_leaf, "Root should not be leaf after split"
|
||||
assert root.split_horizontal == True, "Split should be horizontal"
|
||||
assert root.split_position == 40, "Split position should be 40"
|
||||
|
||||
# Check children exist
|
||||
left = root.left
|
||||
right = root.right
|
||||
assert left is not None, "Left child should exist"
|
||||
assert right is not None, "Right child should exist"
|
||||
assert left.is_leaf, "Left child should be leaf"
|
||||
assert right.is_leaf, "Right child should be leaf"
|
||||
|
||||
print(" BSP split_once: PASS")
|
||||
|
||||
def test_bsp_split_recursive():
|
||||
"""Test recursive splitting (#202)."""
|
||||
print("Testing BSP split_recursive...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
|
||||
# Recursive split with seed for reproducibility
|
||||
result = bsp.split_recursive(depth=3, min_size=(8, 8), max_ratio=1.5, seed=42)
|
||||
assert result is bsp, "split_recursive should return self"
|
||||
|
||||
# Count leaves
|
||||
leaves = list(bsp.leaves())
|
||||
assert len(leaves) > 1, f"Should have multiple leaves, got {len(leaves)}"
|
||||
assert len(leaves) <= 8, f"Should have at most 2^3=8 leaves, got {len(leaves)}"
|
||||
|
||||
# All leaves should be within bounds
|
||||
for leaf in leaves:
|
||||
x, y = leaf.bounds[0]
|
||||
w, h = leaf.bounds[1]
|
||||
assert x >= 0 and y >= 0, f"Leaf position out of bounds: {leaf.bounds}"
|
||||
assert x + w <= 80 and y + h <= 60, f"Leaf extends beyond bounds: {leaf.bounds}"
|
||||
assert w >= 8 and h >= 8, f"Leaf smaller than min_size: {leaf.bounds}"
|
||||
|
||||
print(" BSP split_recursive: PASS")
|
||||
|
||||
def test_bsp_clear():
|
||||
"""Test clear operation (#202)."""
|
||||
print("Testing BSP clear...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 80))
|
||||
bsp.split_recursive(depth=4, min_size=(8, 8), seed=42)
|
||||
|
||||
# Should have multiple leaves
|
||||
leaves_before = len(list(bsp.leaves()))
|
||||
assert leaves_before > 1, "Should have multiple leaves after split"
|
||||
|
||||
# Clear
|
||||
result = bsp.clear()
|
||||
assert result is bsp, "clear should return self"
|
||||
|
||||
# Should be back to single leaf
|
||||
leaves_after = list(bsp.leaves())
|
||||
assert len(leaves_after) == 1, f"Should have 1 leaf after clear, got {len(leaves_after)}"
|
||||
|
||||
# Root should be a leaf with original bounds
|
||||
assert bsp.root.is_leaf, "Root should be leaf after clear"
|
||||
assert bsp.bounds == ((0, 0), (100, 80)), "Bounds should be restored"
|
||||
|
||||
print(" BSP clear: PASS")
|
||||
|
||||
def test_bspnode_properties():
|
||||
"""Test BSPNode properties (#203)."""
|
||||
print("Testing BSPNode properties...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 80))
|
||||
bsp.split_recursive(depth=3, min_size=(8, 8), seed=42)
|
||||
|
||||
root = bsp.root
|
||||
|
||||
# Root properties
|
||||
assert root.level == 0, f"Root level should be 0, got {root.level}"
|
||||
assert root.parent is None, "Root parent should be None"
|
||||
assert not root.is_leaf, "Split root should not be leaf"
|
||||
|
||||
# Split properties (not leaf)
|
||||
assert root.split_horizontal is not None, "Split horizontal should be bool for non-leaf"
|
||||
assert root.split_position is not None, "Split position should be int for non-leaf"
|
||||
|
||||
# Navigate to a leaf
|
||||
current = root
|
||||
while not current.is_leaf:
|
||||
current = current.left
|
||||
|
||||
# Leaf properties
|
||||
assert current.is_leaf, "Should be a leaf"
|
||||
assert current.split_horizontal is None, "Leaf split_horizontal should be None"
|
||||
assert current.split_position is None, "Leaf split_position should be None"
|
||||
assert current.level > 0, "Leaf level should be > 0"
|
||||
|
||||
# Test center method
|
||||
bounds = current.bounds
|
||||
cx, cy = current.center()
|
||||
expected_cx = bounds[0][0] + bounds[1][0] // 2
|
||||
expected_cy = bounds[0][1] + bounds[1][1] // 2
|
||||
assert cx == expected_cx, f"Center x mismatch: {cx} != {expected_cx}"
|
||||
assert cy == expected_cy, f"Center y mismatch: {cy} != {expected_cy}"
|
||||
|
||||
print(" BSPNode properties: PASS")
|
||||
|
||||
def test_bspnode_navigation():
|
||||
"""Test BSPNode navigation (#203)."""
|
||||
print("Testing BSPNode navigation...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(100, 80))
|
||||
bsp.split_once(horizontal=True, position=40)
|
||||
|
||||
root = bsp.root
|
||||
left = root.left
|
||||
right = root.right
|
||||
|
||||
# Parent navigation
|
||||
assert left.parent is not None, "Left parent should exist"
|
||||
assert left.parent.bounds == root.bounds, "Left parent should be root"
|
||||
|
||||
# Sibling navigation
|
||||
assert left.sibling is not None, "Left sibling should exist"
|
||||
assert left.sibling.bounds == right.bounds, "Left sibling should be right"
|
||||
assert right.sibling is not None, "Right sibling should exist"
|
||||
assert right.sibling.bounds == left.bounds, "Right sibling should be left"
|
||||
|
||||
# Root has no parent or sibling
|
||||
assert root.parent is None, "Root parent should be None"
|
||||
assert root.sibling is None, "Root sibling should be None"
|
||||
|
||||
print(" BSPNode navigation: PASS")
|
||||
|
||||
def test_bspnode_contains():
|
||||
"""Test BSPNode contains method (#203)."""
|
||||
print("Testing BSPNode contains...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(10, 20), size=(50, 40)) # x: 10-60, y: 20-60
|
||||
|
||||
root = bsp.root
|
||||
|
||||
# Inside
|
||||
assert root.contains((30, 40)), "Center should be inside"
|
||||
assert root.contains((10, 20)), "Top-left corner should be inside"
|
||||
assert root.contains((59, 59)), "Near bottom-right should be inside"
|
||||
|
||||
# Outside
|
||||
assert not root.contains((5, 40)), "Left of bounds should be outside"
|
||||
assert not root.contains((65, 40)), "Right of bounds should be outside"
|
||||
assert not root.contains((30, 15)), "Above bounds should be outside"
|
||||
assert not root.contains((30, 65)), "Below bounds should be outside"
|
||||
|
||||
print(" BSPNode contains: PASS")
|
||||
|
||||
def test_bsp_leaves_iteration():
|
||||
"""Test leaves iteration (#204)."""
|
||||
print("Testing BSP leaves iteration...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
bsp.split_recursive(depth=3, min_size=(8, 8), seed=42)
|
||||
|
||||
# Iterate leaves
|
||||
leaves = list(bsp.leaves())
|
||||
assert len(leaves) > 0, "Should have at least one leaf"
|
||||
|
||||
# All should be leaves
|
||||
for leaf in leaves:
|
||||
assert leaf.is_leaf, f"Should be leaf: {leaf}"
|
||||
|
||||
# Can convert to list multiple times
|
||||
leaves2 = list(bsp.leaves())
|
||||
assert len(leaves) == len(leaves2), "Multiple iterations should yield same count"
|
||||
|
||||
print(" BSP leaves iteration: PASS")
|
||||
|
||||
def test_bsp_traverse():
|
||||
"""Test traverse with different orders (#204)."""
|
||||
print("Testing BSP traverse...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
bsp.split_recursive(depth=2, min_size=(8, 8), seed=42)
|
||||
|
||||
# Test all traversal orders
|
||||
orders = [
|
||||
mcrfpy.Traversal.PRE_ORDER,
|
||||
mcrfpy.Traversal.IN_ORDER,
|
||||
mcrfpy.Traversal.POST_ORDER,
|
||||
mcrfpy.Traversal.LEVEL_ORDER,
|
||||
mcrfpy.Traversal.INVERTED_LEVEL_ORDER,
|
||||
]
|
||||
|
||||
for order in orders:
|
||||
nodes = list(bsp.traverse(order=order))
|
||||
assert len(nodes) > 0, f"Should have nodes for {order}"
|
||||
# All traversals should visit same number of nodes
|
||||
assert len(nodes) == len(list(bsp.traverse())), f"Node count mismatch for {order}"
|
||||
|
||||
# Default should be LEVEL_ORDER
|
||||
default_nodes = list(bsp.traverse())
|
||||
level_nodes = list(bsp.traverse(mcrfpy.Traversal.LEVEL_ORDER))
|
||||
assert len(default_nodes) == len(level_nodes), "Default should match LEVEL_ORDER"
|
||||
|
||||
# PRE_ORDER: root first
|
||||
pre_nodes = list(bsp.traverse(mcrfpy.Traversal.PRE_ORDER))
|
||||
assert pre_nodes[0].bounds == bsp.root.bounds, "PRE_ORDER should start with root"
|
||||
|
||||
# POST_ORDER: root last
|
||||
post_nodes = list(bsp.traverse(mcrfpy.Traversal.POST_ORDER))
|
||||
assert post_nodes[-1].bounds == bsp.root.bounds, "POST_ORDER should end with root"
|
||||
|
||||
print(" BSP traverse: PASS")
|
||||
|
||||
def test_bsp_find():
|
||||
"""Test find method (#205)."""
|
||||
print("Testing BSP find...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
bsp.split_recursive(depth=3, min_size=(8, 8), seed=42)
|
||||
|
||||
# Find a point inside bounds
|
||||
node = bsp.find((40, 30))
|
||||
assert node is not None, "Should find node for point inside"
|
||||
assert node.is_leaf, "Found node should be a leaf (deepest)"
|
||||
assert node.contains((40, 30)), "Found node should contain the point"
|
||||
|
||||
# Find at corner
|
||||
corner_node = bsp.find((0, 0))
|
||||
assert corner_node is not None, "Should find node at corner"
|
||||
assert corner_node.contains((0, 0)), "Corner node should contain (0,0)"
|
||||
|
||||
# Find outside bounds
|
||||
outside = bsp.find((100, 100))
|
||||
assert outside is None, "Should return None for point outside"
|
||||
|
||||
print(" BSP find: PASS")
|
||||
|
||||
def test_bsp_to_heightmap():
|
||||
"""Test to_heightmap conversion (#206)."""
|
||||
print("Testing BSP to_heightmap...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(50, 40))
|
||||
bsp.split_recursive(depth=2, min_size=(8, 8), seed=42)
|
||||
|
||||
# Basic conversion
|
||||
hmap = bsp.to_heightmap()
|
||||
assert hmap is not None, "Should create heightmap"
|
||||
assert hmap.size == (50, 40), f"Size should match bounds, got {hmap.size}"
|
||||
|
||||
# Check that leaves are filled
|
||||
leaves = list(bsp.leaves())
|
||||
for leaf in leaves:
|
||||
lx, ly = leaf.bounds[0]
|
||||
val = hmap[lx, ly]
|
||||
assert val == 1.0, f"Leaf interior should be 1.0, got {val}"
|
||||
|
||||
# Test with shrink
|
||||
hmap_shrink = bsp.to_heightmap(shrink=2)
|
||||
assert hmap_shrink is not None, "Should create with shrink"
|
||||
|
||||
# Test with select='internal'
|
||||
hmap_internal = bsp.to_heightmap(select='internal')
|
||||
assert hmap_internal is not None, "Should create with select=internal"
|
||||
|
||||
# Test with select='all'
|
||||
hmap_all = bsp.to_heightmap(select='all')
|
||||
assert hmap_all is not None, "Should create with select=all"
|
||||
|
||||
# Test with custom size
|
||||
hmap_sized = bsp.to_heightmap(size=(100, 80))
|
||||
assert hmap_sized.size == (100, 80), f"Custom size should work, got {hmap_sized.size}"
|
||||
|
||||
# Test with custom value
|
||||
hmap_val = bsp.to_heightmap(value=0.5)
|
||||
leaves = list(bsp.leaves())
|
||||
leaf = leaves[0]
|
||||
lx, ly = leaf.bounds[0]
|
||||
val = hmap_val[lx, ly]
|
||||
assert val == 0.5, f"Custom value should be 0.5, got {val}"
|
||||
|
||||
print(" BSP to_heightmap: PASS")
|
||||
|
||||
def test_traversal_enum():
|
||||
"""Test Traversal enum (#204)."""
|
||||
print("Testing Traversal enum...")
|
||||
|
||||
# Check enum exists
|
||||
assert hasattr(mcrfpy, 'Traversal'), "Traversal enum should exist"
|
||||
|
||||
# Check all members
|
||||
assert mcrfpy.Traversal.PRE_ORDER.value == 0
|
||||
assert mcrfpy.Traversal.IN_ORDER.value == 1
|
||||
assert mcrfpy.Traversal.POST_ORDER.value == 2
|
||||
assert mcrfpy.Traversal.LEVEL_ORDER.value == 3
|
||||
assert mcrfpy.Traversal.INVERTED_LEVEL_ORDER.value == 4
|
||||
|
||||
print(" Traversal enum: PASS")
|
||||
|
||||
def test_bsp_chaining():
|
||||
"""Test method chaining."""
|
||||
print("Testing BSP method chaining...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
|
||||
# Chain multiple operations
|
||||
result = bsp.split_recursive(depth=2, min_size=(8, 8), seed=42).clear().split_once(True, 30)
|
||||
assert result is bsp, "Chaining should return self"
|
||||
|
||||
print(" BSP chaining: PASS")
|
||||
|
||||
def test_bsp_repr():
|
||||
"""Test repr output."""
|
||||
print("Testing BSP repr...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
repr_str = repr(bsp)
|
||||
assert "BSP" in repr_str, f"repr should contain BSP: {repr_str}"
|
||||
assert "80" in repr_str and "60" in repr_str, f"repr should contain size: {repr_str}"
|
||||
|
||||
bsp.split_recursive(depth=2, min_size=(8, 8), seed=42)
|
||||
repr_str2 = repr(bsp)
|
||||
assert "leaves" in repr_str2, f"repr should mention leaves: {repr_str2}"
|
||||
|
||||
# BSPNode repr
|
||||
node_repr = repr(bsp.root)
|
||||
assert "BSPNode" in node_repr, f"BSPNode repr: {node_repr}"
|
||||
|
||||
print(" BSP repr: PASS")
|
||||
|
||||
def test_bsp_stale_node_detection():
|
||||
"""Test that stale nodes are detected after clear()/split_recursive() (#review)."""
|
||||
print("Testing BSP stale node detection...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
bsp.split_recursive(depth=2, min_size=(8, 8), seed=42)
|
||||
|
||||
# Save reference to a node
|
||||
old_root = bsp.root
|
||||
old_leaf = list(bsp.leaves())[0]
|
||||
|
||||
# Clear invalidates all nodes
|
||||
bsp.clear()
|
||||
|
||||
# Accessing stale node should raise RuntimeError
|
||||
try:
|
||||
_ = old_root.bounds
|
||||
assert False, "Accessing stale root bounds should raise RuntimeError"
|
||||
except RuntimeError as e:
|
||||
assert "stale" in str(e).lower() or "invalid" in str(e).lower(), \
|
||||
f"Error should mention staleness: {e}"
|
||||
|
||||
try:
|
||||
_ = old_leaf.is_leaf
|
||||
assert False, "Accessing stale leaf should raise RuntimeError"
|
||||
except RuntimeError as e:
|
||||
pass # Expected
|
||||
|
||||
# split_recursive also invalidates (rebuilds tree)
|
||||
bsp2 = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
bsp2.split_recursive(depth=2, min_size=(8, 8), seed=42)
|
||||
saved_node = bsp2.root
|
||||
bsp2.split_recursive(depth=3, min_size=(8, 8), seed=99)
|
||||
|
||||
try:
|
||||
_ = saved_node.bounds
|
||||
assert False, "Accessing node after split_recursive should raise RuntimeError"
|
||||
except RuntimeError:
|
||||
pass # Expected
|
||||
|
||||
print(" BSP stale node detection: PASS")
|
||||
|
||||
def test_bsp_grid_max_validation():
|
||||
"""Test GRID_MAX validation (#review)."""
|
||||
print("Testing BSP GRID_MAX validation...")
|
||||
|
||||
# Should succeed with valid size
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(1000, 1000))
|
||||
assert bsp is not None
|
||||
|
||||
# Should fail with size exceeding GRID_MAX (8192)
|
||||
try:
|
||||
bsp_too_big = mcrfpy.BSP(pos=(0, 0), size=(10000, 100))
|
||||
assert False, "Should raise ValueError for size > GRID_MAX"
|
||||
except ValueError as e:
|
||||
assert "8192" in str(e) or "exceed" in str(e).lower(), f"Error should mention limit: {e}"
|
||||
|
||||
try:
|
||||
bsp_too_big = mcrfpy.BSP(pos=(0, 0), size=(100, 10000))
|
||||
assert False, "Should raise ValueError for height > GRID_MAX"
|
||||
except ValueError as e:
|
||||
pass # Expected
|
||||
|
||||
print(" BSP GRID_MAX validation: PASS")
|
||||
|
||||
def test_bsp_depth_cap():
|
||||
"""Test depth is capped at 16 (#review)."""
|
||||
print("Testing BSP depth cap...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(1000, 1000))
|
||||
|
||||
# Should cap depth at 16 or raise ValueError
|
||||
try:
|
||||
bsp.split_recursive(depth=20, min_size=(1, 1), seed=42)
|
||||
# If it succeeds, verify reasonable number of leaves (not 2^20)
|
||||
leaves = list(bsp.leaves())
|
||||
assert len(leaves) <= 2**16, f"Too many leaves: {len(leaves)}"
|
||||
except ValueError as e:
|
||||
# It's also acceptable to reject excessive depth
|
||||
assert "16" in str(e) or "depth" in str(e).lower(), f"Error should mention depth limit: {e}"
|
||||
|
||||
print(" BSP depth cap: PASS")
|
||||
|
||||
def test_bsp_len():
|
||||
"""Test __len__ returns leaf count (#review)."""
|
||||
print("Testing BSP __len__...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
assert len(bsp) == 1, f"Unsplit BSP should have 1 leaf, got {len(bsp)}"
|
||||
|
||||
bsp.split_once(horizontal=True, position=30)
|
||||
assert len(bsp) == 2, f"After one split should have 2 leaves, got {len(bsp)}"
|
||||
|
||||
bsp.clear()
|
||||
bsp.split_recursive(depth=3, min_size=(8, 8), seed=42)
|
||||
expected = len(list(bsp.leaves()))
|
||||
assert len(bsp) == expected, f"len() mismatch: {len(bsp)} != {expected}"
|
||||
|
||||
print(" BSP __len__: PASS")
|
||||
|
||||
def test_bsp_iter():
|
||||
"""Test __iter__ as shorthand for leaves() (#review)."""
|
||||
print("Testing BSP __iter__...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
bsp.split_recursive(depth=2, min_size=(8, 8), seed=42)
|
||||
|
||||
# Direct iteration should yield same results as leaves()
|
||||
iter_list = list(bsp)
|
||||
leaves_list = list(bsp.leaves())
|
||||
|
||||
assert len(iter_list) == len(leaves_list), \
|
||||
f"Iterator count mismatch: {len(iter_list)} != {len(leaves_list)}"
|
||||
|
||||
# All items should be leaves
|
||||
for node in bsp:
|
||||
assert node.is_leaf, f"Iterator should yield leaves: {node}"
|
||||
|
||||
# Can iterate multiple times
|
||||
count1 = sum(1 for _ in bsp)
|
||||
count2 = sum(1 for _ in bsp)
|
||||
assert count1 == count2, "Should be able to iterate multiple times"
|
||||
|
||||
print(" BSP __iter__: PASS")
|
||||
|
||||
def test_bspnode_equality():
|
||||
"""Test BSPNode __eq__ comparison (#review)."""
|
||||
print("Testing BSPNode equality...")
|
||||
|
||||
bsp = mcrfpy.BSP(pos=(0, 0), size=(80, 60))
|
||||
bsp.split_recursive(depth=2, min_size=(8, 8), seed=42)
|
||||
|
||||
# Same node should be equal
|
||||
root1 = bsp.root
|
||||
root2 = bsp.root
|
||||
assert root1 == root2, "Same node should be equal"
|
||||
|
||||
# Different nodes should not be equal
|
||||
leaves = list(bsp.leaves())
|
||||
assert len(leaves) >= 2, "Need at least 2 leaves for comparison"
|
||||
assert leaves[0] != leaves[1], "Different nodes should not be equal"
|
||||
|
||||
# Parent vs child should not be equal
|
||||
root = bsp.root
|
||||
left = root.left
|
||||
assert root != left, "Parent and child should not be equal"
|
||||
|
||||
# Not equal to non-BSPNode
|
||||
assert not (root == "not a node"), "BSPNode should not equal string"
|
||||
assert not (root == 42), "BSPNode should not equal int"
|
||||
|
||||
print(" BSPNode equality: PASS")
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all BSP tests."""
|
||||
print("\n=== BSP Unit Tests ===\n")
|
||||
|
||||
try:
|
||||
test_bsp_construction()
|
||||
test_bsp_split_once()
|
||||
test_bsp_split_recursive()
|
||||
test_bsp_clear()
|
||||
test_bspnode_properties()
|
||||
test_bspnode_navigation()
|
||||
test_bspnode_contains()
|
||||
test_bsp_leaves_iteration()
|
||||
test_bsp_traverse()
|
||||
test_bsp_find()
|
||||
test_bsp_to_heightmap()
|
||||
test_traversal_enum()
|
||||
test_bsp_chaining()
|
||||
test_bsp_repr()
|
||||
test_bsp_stale_node_detection()
|
||||
test_bsp_grid_max_validation()
|
||||
test_bsp_depth_cap()
|
||||
test_bsp_len()
|
||||
test_bsp_iter()
|
||||
test_bspnode_equality()
|
||||
|
||||
print("\n=== ALL BSP TESTS PASSED ===\n")
|
||||
sys.exit(0)
|
||||
except AssertionError as e:
|
||||
print(f"\nTEST FAILED: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"\nUNEXPECTED ERROR: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_all_tests()
|
||||
Loading…
Add table
Add a link
Reference in a new issue