fix(ui): maintain the parent link in every collection mutator
The parent link (#122/#183) is meant to be the inverse of collection
membership, but only append() maintained it.
insert(), extend() and setitem() called setParent(owner.lock())
unconditionally. A scene collection has no owner drawable, so that
resolved to null -- and setParent(nullptr) also clears parent_scene. The
drawable landed in the scene's UI vector, rendered fine, and reported a
.parent of None.
The slice arms never touched the link at all. A slice-delete left the
removed child pointing at a parent that no longer contained it; a
slice-assign never parented the incoming items, nor unparented the ones
they displaced. A stale parent is a live weak_ptr to a frame the child
has left, and it is what get_global_position() walks.
Route every mutator through link_child()/unlink_child(), which are now
the single place that knows a scene parents via setParentScene() and a
drawable via setParent().
Three latent traps in the same functions, found while fixing this:
* collection[-1] on an EMPTY collection hung the engine forever:
`while (index < 0) index += size()` adds zero on every pass.
* getitem's `index > size() - 1` check underflowed to SIZE_MAX when
size() was 0, so the bounds check passed and it indexed an empty
vector.
* insert() and setitem() resolved the index before detaching the
incoming drawable from its old parent. If it was already a member of
the same collection, detaching erased it and shifted everything
after -- so an index validated against the old size could write past
the end. Slice-assign now builds its result as an independent vector
for the same reason.
closes #377
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
57db8cb87b
commit
53234288ad
3 changed files with 370 additions and 56 deletions
File diff suppressed because one or more lines are too long
|
|
@ -23,6 +23,31 @@ static PyObject* convertDrawableToPython(std::shared_ptr<UIDrawable> drawable) {
|
||||||
return UIDrawable::pyobject_for(drawable);
|
return UIDrawable::pyobject_for(drawable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #377: the parent link is the inverse of collection membership, so EVERY mutator
|
||||||
|
// has to maintain it -- not just append(), which was the only one that did. These
|
||||||
|
// two helpers are the single place that knows how: a scene collection parents via
|
||||||
|
// setParentScene() (it has no owner drawable, so owner.lock() is null and
|
||||||
|
// setParent() would silently unparent the child), a drawable-owned one via
|
||||||
|
// setParent().
|
||||||
|
//
|
||||||
|
// #373: they are also the pin points. setParent()/setParentScene() re-evaluate the
|
||||||
|
// strong ref that keeps a Python subclass wrapper alive while its C++ object is in
|
||||||
|
// a collection, so linking and unlinking here is what makes the pin correct.
|
||||||
|
static void link_child(PyUICollectionObject* self, std::shared_ptr<UIDrawable> drawable) {
|
||||||
|
if (!self->scene_name.empty()) {
|
||||||
|
drawable->setParentScene(self->scene_name);
|
||||||
|
} else {
|
||||||
|
drawable->setParent(self->owner.lock());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call BEFORE erasing from the vector: setParent(nullptr) may drop the last strong
|
||||||
|
// ref to the wrapper, whose dealloc releases its shared_ptr to the drawable. While
|
||||||
|
// the vector still holds the element, `drawable` cannot be freed under us.
|
||||||
|
static void unlink_child(std::shared_ptr<UIDrawable> drawable) {
|
||||||
|
drawable->setParent(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to extract shared_ptr<UIDrawable> from any UIDrawable Python subclass.
|
// Helper to extract shared_ptr<UIDrawable> from any UIDrawable Python subclass.
|
||||||
// Returns nullptr without setting an error if the type doesn't match.
|
// Returns nullptr without setting an error if the type doesn't match.
|
||||||
static std::shared_ptr<UIDrawable> extractDrawable(PyObject* o) {
|
static std::shared_ptr<UIDrawable> extractDrawable(PyObject* o) {
|
||||||
|
|
@ -109,8 +134,12 @@ PyObject* UICollection::getitem(PyUICollectionObject* self, Py_ssize_t index) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
while (index < 0) index += self->data->size();
|
// Same two traps as setitem(): the old `while (index < 0) index += size()` spun
|
||||||
if (index > self->data->size() - 1)
|
// forever on an empty collection, and `index > size() - 1` underflowed to SIZE_MAX
|
||||||
|
// when size() was 0 -- so the bounds check passed and we indexed an empty vector.
|
||||||
|
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, "UICollection index out of range");
|
PyErr_SetString(PyExc_IndexError, "UICollection index out of range");
|
||||||
return NULL;
|
return NULL;
|
||||||
|
|
@ -128,11 +157,13 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle negative indexing
|
// Handle negative indexing. NOT a `while (index < 0) index += size()` loop: on an
|
||||||
while (index < 0) index += self->data->size();
|
// empty collection that adds zero forever, and `children[-1] = x` hung the engine.
|
||||||
|
Py_ssize_t size = static_cast<Py_ssize_t>(self->data->size());
|
||||||
|
if (index < 0) index += size;
|
||||||
|
|
||||||
// Bounds check
|
// Bounds check
|
||||||
if (index >= self->data->size()) {
|
if (index < 0 || index >= size) {
|
||||||
PyErr_SetString(PyExc_IndexError, "UICollection assignment index out of range");
|
PyErr_SetString(PyExc_IndexError, "UICollection assignment index out of range");
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +171,7 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
|
||||||
// Handle deletion
|
// Handle deletion
|
||||||
if (value == NULL) {
|
if (value == NULL) {
|
||||||
// #122: Clear the parent before removing
|
// #122: Clear the parent before removing
|
||||||
(*self->data)[index]->setParent(nullptr);
|
unlink_child((*self->data)[index]);
|
||||||
self->data->erase(self->data->begin() + index);
|
self->data->erase(self->data->begin() + index);
|
||||||
// #288: Invalidate parent Frame's render cache
|
// #288: Invalidate parent Frame's render cache
|
||||||
auto owner_ptr = self->owner.lock();
|
auto owner_ptr = self->owner.lock();
|
||||||
|
|
@ -156,21 +187,30 @@ int UICollection::setitem(PyUICollectionObject* self, Py_ssize_t index, PyObject
|
||||||
PyErr_SetString(PyExc_TypeError, "UICollection can only contain Drawable objects");
|
PyErr_SetString(PyExc_TypeError, "UICollection can only contain Drawable objects");
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
int old_z_index = (*vec)[index]->z_index; // Preserve the z_index
|
// #183: Remove from old parent, which may be a scene rather than a drawable.
|
||||||
|
// Do this BEFORE indexing: if the incoming drawable is already a member of THIS
|
||||||
|
// collection, detaching it erases it from `vec` and shifts every later element,
|
||||||
|
// so an index validated against the old size could write past the end.
|
||||||
|
new_drawable->removeFromParent();
|
||||||
|
|
||||||
|
if (index >= static_cast<Py_ssize_t>(vec->size())) {
|
||||||
|
PyErr_SetString(PyExc_IndexError, "UICollection assignment index out of range");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hold a strong ref: unlinking may release the last ref to the old element's
|
||||||
|
// Python wrapper, whose dealloc drops the wrapper's shared_ptr to it.
|
||||||
|
std::shared_ptr<UIDrawable> old_drawable = (*vec)[index];
|
||||||
|
int old_z_index = old_drawable->z_index; // Preserve the z_index
|
||||||
|
|
||||||
// #122: Clear parent of old element
|
// #122: Clear parent of old element
|
||||||
(*vec)[index]->setParent(nullptr);
|
unlink_child(old_drawable);
|
||||||
|
|
||||||
// #122: Remove new drawable from its old parent if it has one
|
|
||||||
if (auto old_parent = new_drawable->getParent()) {
|
|
||||||
new_drawable->removeFromParent();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Preserve the z_index of the replaced element
|
// Preserve the z_index of the replaced element
|
||||||
new_drawable->z_index = old_z_index;
|
new_drawable->z_index = old_z_index;
|
||||||
|
|
||||||
// #122: Set new parent
|
// #377: parent to the scene, not to a null owner, when this is a scene collection
|
||||||
new_drawable->setParent(self->owner.lock());
|
link_child(self, new_drawable);
|
||||||
|
|
||||||
// Replace the element
|
// Replace the element
|
||||||
(*vec)[index] = new_drawable;
|
(*vec)[index] = new_drawable;
|
||||||
|
|
@ -367,10 +407,16 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
|
||||||
// Sort in descending order and delete
|
// Sort in descending order and delete
|
||||||
std::sort(indices.begin(), indices.end(), std::greater<Py_ssize_t>());
|
std::sort(indices.begin(), indices.end(), std::greater<Py_ssize_t>());
|
||||||
for (Py_ssize_t idx : indices) {
|
for (Py_ssize_t idx : indices) {
|
||||||
|
// #377: unlink before erasing -- a slice delete used to leave the
|
||||||
|
// removed child pointing at a parent that no longer contains it
|
||||||
|
unlink_child((*self->data)[idx]);
|
||||||
self->data->erase(self->data->begin() + idx);
|
self->data->erase(self->data->begin() + idx);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Contiguous slice - can delete in one go
|
// Contiguous slice - can delete in one go
|
||||||
|
for (Py_ssize_t i = start; i < stop; i++) {
|
||||||
|
unlink_child((*self->data)[i]);
|
||||||
|
}
|
||||||
self->data->erase(self->data->begin() + start, self->data->begin() + stop);
|
self->data->erase(self->data->begin() + start, self->data->begin() + stop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,38 +465,67 @@ int UICollection::ass_subscript(PyUICollectionObject* self, PyObject* key, PyObj
|
||||||
new_items.push_back(drawable);
|
new_items.push_back(drawable);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now perform the assignment
|
if (step != 1 && slicelength != value_len) {
|
||||||
if (step == 1) {
|
|
||||||
// Contiguous slice
|
|
||||||
if (slicelength != value_len) {
|
|
||||||
// Need to resize
|
|
||||||
auto it_start = self->data->begin() + start;
|
|
||||||
auto it_stop = self->data->begin() + stop;
|
|
||||||
self->data->erase(it_start, it_stop);
|
|
||||||
self->data->insert(self->data->begin() + start, new_items.begin(), new_items.end());
|
|
||||||
} else {
|
|
||||||
// Same size, just replace
|
|
||||||
for (Py_ssize_t i = 0; i < slicelength; i++) {
|
|
||||||
// Preserve z_index
|
|
||||||
new_items[i]->z_index = (*self->data)[start + i]->z_index;
|
|
||||||
(*self->data)[start + i] = new_items[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Extended slice
|
|
||||||
if (slicelength != value_len) {
|
|
||||||
PyErr_Format(PyExc_ValueError,
|
PyErr_Format(PyExc_ValueError,
|
||||||
"attempt to assign sequence of size %zd to extended slice of size %zd",
|
"attempt to assign sequence of size %zd to extended slice of size %zd",
|
||||||
value_len, slicelength);
|
value_len, slicelength);
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #377: build the post-assignment contents as an independent vector before
|
||||||
|
// touching anything. An incoming item may already be a member of THIS
|
||||||
|
// collection, and detaching it from its old parent mutates the very vector
|
||||||
|
// we would otherwise be indexing into -- shifting `start`/`stop` out from
|
||||||
|
// under the erase. Assembling a copy first makes aliasing a non-issue.
|
||||||
|
//
|
||||||
|
// `previous` also holds a strong ref to every displaced element, so
|
||||||
|
// unlinking one (which may release the last ref to its Python wrapper,
|
||||||
|
// whose dealloc drops the wrapper's shared_ptr) cannot free it under us.
|
||||||
|
std::vector<std::shared_ptr<UIDrawable>> previous = *self->data;
|
||||||
|
std::vector<std::shared_ptr<UIDrawable>> result = previous;
|
||||||
|
|
||||||
|
if (step == 1) {
|
||||||
|
if (slicelength != value_len) {
|
||||||
|
result.erase(result.begin() + start, result.begin() + stop);
|
||||||
|
result.insert(result.begin() + start, new_items.begin(), new_items.end());
|
||||||
|
} else {
|
||||||
|
for (Py_ssize_t i = 0; i < slicelength; i++) {
|
||||||
|
new_items[i]->z_index = previous[start + i]->z_index; // Preserve z_index
|
||||||
|
result[start + i] = new_items[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) {
|
for (Py_ssize_t i = 0, cur = start; i < slicelength; i++, cur += step) {
|
||||||
// Preserve z_index
|
new_items[i]->z_index = previous[cur]->z_index; // Preserve z_index
|
||||||
new_items[i]->z_index = (*self->data)[cur]->z_index;
|
result[cur] = new_items[i];
|
||||||
(*self->data)[cur] = new_items[i];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto contains_ptr = [](const std::vector<std::shared_ptr<UIDrawable>>& v,
|
||||||
|
const UIDrawable* p) {
|
||||||
|
return std::any_of(v.begin(), v.end(),
|
||||||
|
[p](const std::shared_ptr<UIDrawable>& e) { return e.get() == p; });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detach incoming items from whatever parent they had. Safe to do now:
|
||||||
|
// this can only mutate `*self->data`, which we are about to overwrite.
|
||||||
|
for (auto& item : new_items) {
|
||||||
|
item->removeFromParent();
|
||||||
|
}
|
||||||
|
|
||||||
|
*self->data = result;
|
||||||
|
|
||||||
|
// Anything the slice displaced is no longer a member -- drop its parent link.
|
||||||
|
for (auto& old_item : previous) {
|
||||||
|
if (!contains_ptr(result, old_item.get())) {
|
||||||
|
unlink_child(old_item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (auto& item : result) {
|
||||||
|
link_child(self, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Mark scene as needing resort after slice assignment
|
// Mark scene as needing resort after slice assignment
|
||||||
McRFPy_API::markSceneNeedsSort();
|
McRFPy_API::markSceneNeedsSort();
|
||||||
// #288: Invalidate parent Frame's render cache
|
// #288: Invalidate parent Frame's render cache
|
||||||
|
|
@ -519,12 +594,7 @@ PyObject* UICollection::append(PyUICollectionObject* self, PyObject* o)
|
||||||
drawable->z_index = new_z_index;
|
drawable->z_index = new_z_index;
|
||||||
|
|
||||||
// #183: Set new parent - either scene or drawable
|
// #183: Set new parent - either scene or drawable
|
||||||
if (!self->scene_name.empty()) {
|
link_child(self, drawable);
|
||||||
drawable->setParentScene(self->scene_name);
|
|
||||||
} else {
|
|
||||||
std::shared_ptr<UIDrawable> owner_ptr = self->owner.lock();
|
|
||||||
drawable->setParent(owner_ptr);
|
|
||||||
}
|
|
||||||
|
|
||||||
self->data->push_back(drawable);
|
self->data->push_back(drawable);
|
||||||
|
|
||||||
|
|
@ -576,7 +646,9 @@ PyObject* UICollection::extend(PyUICollectionObject* self, PyObject* iterable)
|
||||||
|
|
||||||
drawable->removeFromParent();
|
drawable->removeFromParent();
|
||||||
drawable->z_index = current_z_index;
|
drawable->z_index = current_z_index;
|
||||||
drawable->setParent(owner_ptr);
|
// #377: was setParent(owner_ptr), which unparented the drawable outright on a
|
||||||
|
// scene collection (no owner drawable => owner.lock() is null)
|
||||||
|
link_child(self, drawable);
|
||||||
self->data->push_back(drawable);
|
self->data->push_back(drawable);
|
||||||
|
|
||||||
Py_DECREF(item);
|
Py_DECREF(item);
|
||||||
|
|
@ -621,7 +693,7 @@ PyObject* UICollection::remove(PyUICollectionObject* self, PyObject* o)
|
||||||
for (auto it = vec->begin(); it != vec->end(); ++it) {
|
for (auto it = vec->begin(); it != vec->end(); ++it) {
|
||||||
if (it->get() == search_drawable.get()) {
|
if (it->get() == search_drawable.get()) {
|
||||||
// #122: Clear the parent before removing
|
// #122: Clear the parent before removing
|
||||||
(*it)->setParent(nullptr);
|
unlink_child(*it);
|
||||||
vec->erase(it);
|
vec->erase(it);
|
||||||
McRFPy_API::markSceneNeedsSort();
|
McRFPy_API::markSceneNeedsSort();
|
||||||
// #288: Invalidate parent Frame's render cache
|
// #288: Invalidate parent Frame's render cache
|
||||||
|
|
@ -671,7 +743,7 @@ PyObject* UICollection::pop(PyUICollectionObject* self, PyObject* args)
|
||||||
std::shared_ptr<UIDrawable> drawable = (*vec)[index];
|
std::shared_ptr<UIDrawable> drawable = (*vec)[index];
|
||||||
|
|
||||||
// #122: Clear the parent before removing
|
// #122: Clear the parent before removing
|
||||||
drawable->setParent(nullptr);
|
unlink_child(drawable);
|
||||||
|
|
||||||
// Remove from vector
|
// Remove from vector
|
||||||
vec->erase(vec->begin() + index);
|
vec->erase(vec->begin() + index);
|
||||||
|
|
@ -711,6 +783,12 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #183: Remove from old parent, which may be a scene rather than a drawable.
|
||||||
|
// Do this BEFORE resolving the index: if the drawable is already a member of
|
||||||
|
// THIS collection, detaching it erases it from `vec`, and an index clamped
|
||||||
|
// against the old size could then land one past the end.
|
||||||
|
drawable->removeFromParent();
|
||||||
|
|
||||||
// Handle negative indexing and clamping (Python list.insert behavior)
|
// Handle negative indexing and clamping (Python list.insert behavior)
|
||||||
Py_ssize_t size = static_cast<Py_ssize_t>(vec->size());
|
Py_ssize_t size = static_cast<Py_ssize_t>(vec->size());
|
||||||
if (index < 0) {
|
if (index < 0) {
|
||||||
|
|
@ -722,13 +800,8 @@ PyObject* UICollection::insert(PyUICollectionObject* self, PyObject* args)
|
||||||
index = size;
|
index = size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #122: Remove from old parent if it has one
|
// #377: parent to the scene, not to a null owner, when this is a scene collection
|
||||||
if (auto old_parent = drawable->getParent()) {
|
link_child(self, drawable);
|
||||||
drawable->removeFromParent();
|
|
||||||
}
|
|
||||||
|
|
||||||
// #122: Set new parent
|
|
||||||
drawable->setParent(self->owner.lock());
|
|
||||||
|
|
||||||
// Insert at position
|
// Insert at position
|
||||||
vec->insert(vec->begin() + index, drawable);
|
vec->insert(vec->begin() + index, drawable);
|
||||||
|
|
|
||||||
241
tests/regression/issue_377_collection_parent_link_test.py
Normal file
241
tests/regression/issue_377_collection_parent_link_test.py
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Issue #377: every UICollection mutator must maintain the parent link.
|
||||||
|
|
||||||
|
`x in collection` and `x.parent is <collection owner>` are supposed to be two views of
|
||||||
|
the same fact (#122/#183). Only append() maintained it. The rest got it wrong two ways:
|
||||||
|
|
||||||
|
* insert/extend/setitem called setParent(owner.lock()) unconditionally. A SCENE
|
||||||
|
collection has no owner drawable, so that resolved to null -- and setParent(nullptr)
|
||||||
|
also clears parent_scene. The drawable landed in the scene's UI vector, rendered
|
||||||
|
fine, and reported .parent of None.
|
||||||
|
|
||||||
|
* The slice arms never touched the link at all: a slice-delete left the removed child
|
||||||
|
pointing at a parent that no longer contained it, and a slice-assign never parented
|
||||||
|
the incoming items nor unparented the ones they displaced.
|
||||||
|
|
||||||
|
Two adjacent traps in the same functions are also covered here: `collection[-1]` on an
|
||||||
|
EMPTY collection hung the engine forever (`while (index < 0) index += size()` adds zero
|
||||||
|
each pass), and getitem's `index > size() - 1` bounds check underflowed to SIZE_MAX when
|
||||||
|
size() was 0, so it went on to index an empty vector.
|
||||||
|
|
||||||
|
This invariant is what #373's identity pin hangs off, so it has to hold for every path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import mcrfpy
|
||||||
|
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
if not cond:
|
||||||
|
failures.append(msg)
|
||||||
|
print(f"FAIL: {msg}")
|
||||||
|
else:
|
||||||
|
print(f" ok: {msg}")
|
||||||
|
|
||||||
|
|
||||||
|
def frame(n=10):
|
||||||
|
return mcrfpy.Frame(pos=(0, 0), size=(n, n))
|
||||||
|
|
||||||
|
|
||||||
|
def check_linked(child, owner, collection, label):
|
||||||
|
"""The two views of membership must agree."""
|
||||||
|
check(child in collection, f"{label}: child is in the collection")
|
||||||
|
check(child.parent is owner, f"{label}: child.parent is the owner (got {child.parent!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def check_unlinked(child, collection, label):
|
||||||
|
check(child not in collection, f"{label}: child is NOT in the collection")
|
||||||
|
check(child.parent is None, f"{label}: child.parent is None (got {child.parent!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scene_collection_mutators():
|
||||||
|
"""A scene collection parents via parent_scene; only append used to do it."""
|
||||||
|
scene = mcrfpy.Scene("link_scene")
|
||||||
|
|
||||||
|
a, b, c, d = frame(), frame(), frame(), frame()
|
||||||
|
|
||||||
|
scene.children.append(a)
|
||||||
|
check_linked(a, scene, scene.children, "scene append")
|
||||||
|
|
||||||
|
scene.children.insert(0, b)
|
||||||
|
check_linked(b, scene, scene.children, "scene insert")
|
||||||
|
|
||||||
|
scene.children.extend([c])
|
||||||
|
check_linked(c, scene, scene.children, "scene extend")
|
||||||
|
|
||||||
|
scene.children[0] = d
|
||||||
|
check_linked(d, scene, scene.children, "scene setitem")
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_collection_mutators():
|
||||||
|
scene = mcrfpy.Scene("link_frame")
|
||||||
|
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
|
||||||
|
scene.children.append(owner)
|
||||||
|
|
||||||
|
a, b, c, d = frame(), frame(), frame(), frame()
|
||||||
|
|
||||||
|
owner.children.append(a)
|
||||||
|
check_linked(a, owner, owner.children, "frame append")
|
||||||
|
|
||||||
|
owner.children.insert(0, b)
|
||||||
|
check_linked(b, owner, owner.children, "frame insert")
|
||||||
|
|
||||||
|
owner.children.extend([c])
|
||||||
|
check_linked(c, owner, owner.children, "frame extend")
|
||||||
|
|
||||||
|
replaced = owner.children[0]
|
||||||
|
owner.children[0] = d
|
||||||
|
check_linked(d, owner, owner.children, "frame setitem")
|
||||||
|
check_unlinked(replaced, owner.children, "frame setitem (displaced element)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_removal_mutators_unlink():
|
||||||
|
scene = mcrfpy.Scene("link_remove")
|
||||||
|
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
|
||||||
|
scene.children.append(owner)
|
||||||
|
|
||||||
|
a, b, c = frame(), frame(), frame()
|
||||||
|
owner.children.extend([a, b, c])
|
||||||
|
|
||||||
|
owner.children.remove(a)
|
||||||
|
check_unlinked(a, owner.children, "frame remove()")
|
||||||
|
|
||||||
|
popped = owner.children.pop(0)
|
||||||
|
check_unlinked(popped, owner.children, "frame pop()")
|
||||||
|
|
||||||
|
del owner.children[0]
|
||||||
|
check_unlinked(c, owner.children, "frame __delitem__")
|
||||||
|
check(len(owner.children) == 0, "the collection is empty after removing everything")
|
||||||
|
|
||||||
|
|
||||||
|
def test_slice_delete_unlinks():
|
||||||
|
scene = mcrfpy.Scene("link_slicedel")
|
||||||
|
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
|
||||||
|
scene.children.append(owner)
|
||||||
|
|
||||||
|
a, b, c, d = frame(), frame(), frame(), frame()
|
||||||
|
owner.children.extend([a, b, c, d])
|
||||||
|
|
||||||
|
del owner.children[0:2] # contiguous
|
||||||
|
check(len(owner.children) == 2, "contiguous slice-delete removed two elements")
|
||||||
|
check_unlinked(a, owner.children, "contiguous slice-delete (a)")
|
||||||
|
check_unlinked(b, owner.children, "contiguous slice-delete (b)")
|
||||||
|
check_linked(c, owner, owner.children, "contiguous slice-delete (survivor c)")
|
||||||
|
|
||||||
|
e, f = frame(), frame()
|
||||||
|
owner.children.extend([e, f]) # now: c, d, e, f
|
||||||
|
del owner.children[0::2] # extended slice: removes c and e
|
||||||
|
check_unlinked(c, owner.children, "extended slice-delete (c)")
|
||||||
|
check_unlinked(e, owner.children, "extended slice-delete (e)")
|
||||||
|
check_linked(d, owner, owner.children, "extended slice-delete (survivor d)")
|
||||||
|
check_linked(f, owner, owner.children, "extended slice-delete (survivor f)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_slice_assign_links():
|
||||||
|
scene = mcrfpy.Scene("link_sliceassign")
|
||||||
|
owner = mcrfpy.Frame(pos=(0, 0), size=(200, 200))
|
||||||
|
scene.children.append(owner)
|
||||||
|
|
||||||
|
a, b = frame(), frame()
|
||||||
|
owner.children.extend([a, b])
|
||||||
|
|
||||||
|
c, d = frame(), frame()
|
||||||
|
owner.children[0:1] = [c, d] # resizing contiguous assign
|
||||||
|
check_linked(c, owner, owner.children, "slice-assign (new element c)")
|
||||||
|
check_linked(d, owner, owner.children, "slice-assign (new element d)")
|
||||||
|
check_unlinked(a, owner.children, "slice-assign (displaced element a)")
|
||||||
|
check_linked(b, owner, owner.children, "slice-assign (untouched element b)")
|
||||||
|
|
||||||
|
e = frame()
|
||||||
|
owner.children[0:1] = [e] # same-size contiguous assign
|
||||||
|
check_linked(e, owner, owner.children, "same-size slice-assign (new element)")
|
||||||
|
check_unlinked(c, owner.children, "same-size slice-assign (displaced element)")
|
||||||
|
|
||||||
|
# Extended slice assign
|
||||||
|
g, h = frame(), frame()
|
||||||
|
owner.children[0::2] = [g, h]
|
||||||
|
check_linked(g, owner, owner.children, "extended slice-assign (g)")
|
||||||
|
check_linked(h, owner, owner.children, "extended slice-assign (h)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_slice_assign_on_scene():
|
||||||
|
"""Slice-assign into a scene collection must parent to the SCENE."""
|
||||||
|
scene = mcrfpy.Scene("link_slicescene")
|
||||||
|
a, b = frame(), frame()
|
||||||
|
scene.children.extend([a, b])
|
||||||
|
|
||||||
|
c = frame()
|
||||||
|
scene.children[0:1] = [c]
|
||||||
|
check_linked(c, scene, scene.children, "scene slice-assign")
|
||||||
|
check_unlinked(a, scene.children, "scene slice-assign (displaced)")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reparent_between_owners():
|
||||||
|
"""Appending to a new owner must remove from the old one, not leave it in both."""
|
||||||
|
scene = mcrfpy.Scene("link_reparent")
|
||||||
|
one = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
|
two = mcrfpy.Frame(pos=(0, 0), size=(100, 100))
|
||||||
|
scene.children.extend([one, two])
|
||||||
|
|
||||||
|
x = frame()
|
||||||
|
one.children.append(x)
|
||||||
|
check_linked(x, one, one.children, "child starts in owner one")
|
||||||
|
|
||||||
|
two.children.append(x)
|
||||||
|
check(x not in one.children, "reparenting removed the child from its old owner")
|
||||||
|
check_linked(x, two, two.children, "reparenting linked the child to its new owner")
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_collection_negative_index():
|
||||||
|
"""`collection[-1]` on an empty collection used to hang the engine forever."""
|
||||||
|
scene = mcrfpy.Scene("link_empty")
|
||||||
|
|
||||||
|
try:
|
||||||
|
scene.children[-1]
|
||||||
|
check(False, "getitem[-1] on an empty collection raises IndexError")
|
||||||
|
except IndexError:
|
||||||
|
check(True, "getitem[-1] on an empty collection raises IndexError")
|
||||||
|
|
||||||
|
try:
|
||||||
|
scene.children[-1] = frame()
|
||||||
|
check(False, "setitem[-1] on an empty collection raises IndexError")
|
||||||
|
except IndexError:
|
||||||
|
check(True, "setitem[-1] on an empty collection raises IndexError")
|
||||||
|
|
||||||
|
# Negative indexing still works when there IS something to index.
|
||||||
|
a, b = frame(), frame()
|
||||||
|
scene.children.extend([a, b])
|
||||||
|
check(scene.children[-1] is b, "negative indexing resolves from the end")
|
||||||
|
check(scene.children[-2] is a, "negative indexing reaches the first element")
|
||||||
|
try:
|
||||||
|
scene.children[-3]
|
||||||
|
check(False, "an out-of-range negative index raises IndexError")
|
||||||
|
except IndexError:
|
||||||
|
check(True, "an out-of-range negative index raises IndexError")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
test_scene_collection_mutators()
|
||||||
|
test_frame_collection_mutators()
|
||||||
|
test_removal_mutators_unlink()
|
||||||
|
test_slice_delete_unlinks()
|
||||||
|
test_slice_assign_links()
|
||||||
|
test_slice_assign_on_scene()
|
||||||
|
test_reparent_between_owners()
|
||||||
|
test_empty_collection_negative_index()
|
||||||
|
|
||||||
|
if failures:
|
||||||
|
print(f"\nFAILED ({len(failures)} checks)")
|
||||||
|
for f in failures:
|
||||||
|
print(f" - {f}")
|
||||||
|
sys.exit(1)
|
||||||
|
print("\nPASS")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue