Fix issues with invalid Outliner entries
The EntityOutlinerListModel was violating the QAbstractItemModel contract in a few cases, as reported by `QAbstractItemModelTester`. The important ones causing issues were: - Entry order was not guaranteed, leading to model indices pointing at invalid data - Parent/child relationships could be temporarily invalid due to a change I made in EditorEntityModel::RemoveEntity to try to avoid an unnecessary reparent operation - as it turned out, the parent/child data was being cached even for recreated entities and not clearing child data could cause issues - `EntityOutlinerListModel::ProcessEntityUpdates` was emitting data changed between two indices that didn't necessarily share a parent, which is [undefined behavior](https://doc.qt.io/qt-5/qabstractitemmodel.html#dataChanged) The other reported issues (that weren't really causing issues with `QTreeView`) were: - The root index had flags other than `Qt::ItemIsDropEnabled` - `rowCount` showed all columns as having children - `parent` showed indices as being parented to a non-0 column This change introduces fixes for the above issues, namely: - Reverts my change to `EditorEntityModel::RemoveEntity` to ensure we don't have invalid parent/child references sitting in the cache - Ensures `EditorEntityModelEntry` child ordering is guaranteed sorted by EntityId, to prevent the `EntityOutlinerListModel` from having indices pointed at invalid data*. - Fixes various model sanity issues, such as `rowCount` being 0 for indices with a non-0 column Two unit tests were added to reproduce the invalid behavior and validate the fix: TestCreateFlatHierarchyUndoAndRedoWorks and TestCreateNestedHierarchyUndoAndRedoWorks This change focuses on correctness over performance. My subjective in-Editor outliner experience is about the same, but it may be worthwhile to expand the test coverage with a benchmarking suite to look into areas for optimization. *As a rough illustration of the previous child ordering behavior, consider the following entity hierarchy: ``` Root (EID 9999) |_ Child1 (EID 2) |_ Child2 (EID 3) |_ Child3 (EID 4) ``` With an representations like the following pseudocode: ``` // EditorEntityModel representation EditorEntityModelEntry root; root.children[0] = 2; root.children[1] = 3; root.children[2] = 4; // EditorOutlinerListModel representation // row, column, user data (64 bit uint) child1 = QModelIndex(0, 0, 2) child2 = QModelIndex(1, 0, 3) child3 = QModelIndex(2, 0, 4) ``` When removing a child, the `EditorEntityModel` used to do roughly the following: ``` // Swap and pop the last child int indexToRemove = 0; swap(root.children[indexToRemove], root.children[root.children.size() - 1]); root.children.resize(root.children.size() - 1); model.notifyRemoved(root, indexToRemove); // model removes the row indicated // Leading to this EditorEntityModel state root.children[0] = 4; root.children[1] = 3; // And this EntityOutlinerListModel state, note that the row indices are swapped from the indices in the backing storage child2 = QModelIndex(0, 0, 3) child3 = QModelIndex(1, 0, 4) ``` A QModelIndex having a row that doesn't match its underlying data is undefined behavior, and was the source of an intermittent crash in our `QSortFilterProxyModel` as subsequent updates to the wrong row led to an invalid proxy state. Signed-off-by: nvsickle <nvsickle@amazon.com>
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright (c) Contributors to the Open 3D Engine Project.
|
||||
* For complete copyright and license terms please see the LICENSE at the root of this distribution.
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*
|
||||
*/
|
||||
|
||||
#include <AzCore/Serialization/SerializeContext.h>
|
||||
#include <AzTest/AzTest.h>
|
||||
|
||||
#include <AzFramework/Entity/EntityContextBus.h>
|
||||
#include <AzToolsFramework/API/ToolsApplicationAPI.h>
|
||||
#include <AzToolsFramework/Entity/EditorEntityContextComponent.h>
|
||||
#include <AzToolsFramework/Entity/PrefabEditorEntityOwnershipInterface.h>
|
||||
#include <AzToolsFramework/ToolsComponents/TransformComponent.h>
|
||||
#include <AzToolsFramework/UI/Outliner/EntityOutlinerListModel.hxx>
|
||||
#include <AzToolsFramework/UI/Prefab/PrefabIntegrationManager.h>
|
||||
#include <AzToolsFramework/Undo/UndoSystem.h>
|
||||
#include <Prefab/PrefabTestFixture.h>
|
||||
|
||||
#include <QAbstractItemModelTester>
|
||||
|
||||
namespace UnitTest
|
||||
{
|
||||
// Test fixture for the entity outliner model that uses a QAbstractItemModelTester to validate the state of the model
|
||||
// when QAbstractItemModel signals fire. Tests will exit with a fatal error if an invalid state is detected.
|
||||
class EntityOutlinerTest : public PrefabTestFixture
|
||||
{
|
||||
protected:
|
||||
void SetUpEditorFixtureImpl() override
|
||||
{
|
||||
PrefabTestFixture::SetUpEditorFixtureImpl();
|
||||
GetApplication()->RegisterComponentDescriptor(AzToolsFramework::EditorEntityContextComponent::CreateDescriptor());
|
||||
|
||||
m_model = AZStd::make_unique<AzToolsFramework::EntityOutlinerListModel>();
|
||||
m_model->Initialize();
|
||||
m_modelTester =
|
||||
AZStd::make_unique<QAbstractItemModelTester>(m_model.get(), QAbstractItemModelTester::FailureReportingMode::Fatal);
|
||||
|
||||
AzToolsFramework::ToolsApplicationRequestBus::BroadcastResult(
|
||||
m_undoStack, &AzToolsFramework::ToolsApplicationRequestBus::Events::GetUndoStack);
|
||||
AZ_Assert(m_undoStack, "Failed to look up undo stack from tools application");
|
||||
|
||||
// Create a new root prefab - the synthetic "NewLevel.prefab" that comes in by default isn't suitable for outliner tests
|
||||
// because it's created before the EditorEntityModel that our EntityOutlinerListModel subscribes to, and we want to
|
||||
// recreate it as part of the fixture regardless.
|
||||
auto entityOwnershipService = AZ::Interface<AzToolsFramework::PrefabEditorEntityOwnershipInterface>::Get();
|
||||
entityOwnershipService->CreateNewLevelPrefab("UnitTestRoot.prefab", "");
|
||||
}
|
||||
|
||||
void TearDownEditorFixtureImpl() override
|
||||
{
|
||||
m_undoStack = nullptr;
|
||||
m_modelTester.reset();
|
||||
m_model.reset();
|
||||
PrefabTestFixture::TearDownEditorFixtureImpl();
|
||||
}
|
||||
|
||||
// Creates an entity with a given name as one undoable operation
|
||||
// Parents to parentId, or the root prefab container entity if parentId is invalid
|
||||
AZ::EntityId CreateNamedEntity(AZStd::string name, AZ::EntityId parentId = AZ::EntityId())
|
||||
{
|
||||
auto createResult = m_prefabPublicInterface->CreateEntity(parentId, AZ::Vector3());
|
||||
AZ_Assert(createResult.IsSuccess(), "Failed to create entity: %s", createResult.GetError().c_str());
|
||||
AZ::EntityId entityId = createResult.GetValue();
|
||||
|
||||
AZ::Entity* entity = nullptr;
|
||||
AZ::ComponentApplicationBus::BroadcastResult(entity, &AZ::ComponentApplicationRequests::FindEntity, entityId);
|
||||
|
||||
entity->Deactivate();
|
||||
|
||||
entity->SetName(name);
|
||||
|
||||
// Normally, in invalid parent ID should automatically parent us to the root prefab, but currently in the unit test
|
||||
// environment entities aren't created with a default transform component, so CreateEntity won't correctly parent.
|
||||
// We get the actual target parent ID here, then create our missing transform component.
|
||||
if (!parentId.IsValid())
|
||||
{
|
||||
auto prefabEditorEntityOwnershipInterface = AZ::Interface<AzToolsFramework::PrefabEditorEntityOwnershipInterface>::Get();
|
||||
parentId = prefabEditorEntityOwnershipInterface->GetRootPrefabInstance()->get().GetContainerEntityId();
|
||||
}
|
||||
|
||||
auto transform = aznew AzToolsFramework::Components::TransformComponent;
|
||||
transform->SetParent(parentId);
|
||||
entity->AddComponent(transform);
|
||||
|
||||
entity->Activate();
|
||||
|
||||
// Update our undo cache entry to include the rename / reparent as one atomic operation.
|
||||
m_prefabPublicInterface->GenerateUndoNodesForEntityChangeAndUpdateCache(entityId, m_undoStack->GetTop());
|
||||
|
||||
// Force a prefab propagation as updates are deferred to the next tick.
|
||||
m_prefabSystemComponent->OnSystemTick();
|
||||
|
||||
return entityId;
|
||||
}
|
||||
|
||||
// Helper to visualize debug state
|
||||
void PrintModel()
|
||||
{
|
||||
AZStd::deque<AZStd::pair<QModelIndex, int>> indices;
|
||||
indices.push_back({ m_model->index(0, 0), 0 });
|
||||
while (!indices.empty())
|
||||
{
|
||||
auto [index, depth] = indices.front();
|
||||
indices.pop_front();
|
||||
|
||||
QString indentString;
|
||||
for (int i = 0; i < depth; ++i)
|
||||
{
|
||||
indentString += " ";
|
||||
}
|
||||
qDebug() << (indentString + index.data(Qt::DisplayRole).toString()) << index.internalId();
|
||||
for (int i = 0; i < m_model->rowCount(index); ++i)
|
||||
{
|
||||
indices.emplace_back(m_model->index(i, 0, index), depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Gets the index of the root prefab, i.e. the "New Level" container entity
|
||||
QModelIndex GetRootIndex() const
|
||||
{
|
||||
return m_model->index(0, 0);
|
||||
}
|
||||
|
||||
AZStd::unique_ptr<AzToolsFramework::EntityOutlinerListModel> m_model;
|
||||
AZStd::unique_ptr<QAbstractItemModelTester> m_modelTester;
|
||||
AzToolsFramework::UndoSystem::UndoStack* m_undoStack = nullptr;
|
||||
};
|
||||
|
||||
TEST_F(EntityOutlinerTest, TestCreateFlatHierarchyUndoAndRedoWorks)
|
||||
{
|
||||
constexpr size_t entityCount = 10;
|
||||
|
||||
for (size_t i = 0; i < entityCount; ++i)
|
||||
{
|
||||
CreateNamedEntity(AZStd::string::format("Entity%zu", i));
|
||||
EXPECT_EQ(m_model->rowCount(GetRootIndex()), i + 1);
|
||||
}
|
||||
m_model->ProcessEntityUpdates();
|
||||
|
||||
for (int i = entityCount; i > 0; --i)
|
||||
{
|
||||
m_undoStack->Undo();
|
||||
EXPECT_EQ(m_model->rowCount(GetRootIndex()), i - 1);
|
||||
}
|
||||
m_model->ProcessEntityUpdates();
|
||||
|
||||
for (size_t i = 0; i < entityCount; ++i)
|
||||
{
|
||||
m_undoStack->Redo();
|
||||
EXPECT_EQ(m_model->rowCount(GetRootIndex()), i + 1);
|
||||
}
|
||||
m_model->ProcessEntityUpdates();
|
||||
}
|
||||
|
||||
TEST_F(EntityOutlinerTest, TestCreateNestedHierarchyUndoAndRedoWorks)
|
||||
{
|
||||
constexpr size_t depth = 5;
|
||||
|
||||
auto modelDepth = [this]() -> int
|
||||
{
|
||||
int depth = 0;
|
||||
QModelIndex index = GetRootIndex();
|
||||
while (m_model->rowCount(index) > 0)
|
||||
{
|
||||
++depth;
|
||||
index = m_model->index(0, 0, index);
|
||||
}
|
||||
return depth;
|
||||
};
|
||||
|
||||
AZ::EntityId parentId;
|
||||
for (int i = 0; i < depth; i++)
|
||||
{
|
||||
parentId = CreateNamedEntity(AZStd::string::format("EntityDepth%i", i), parentId);
|
||||
EXPECT_EQ(modelDepth(), i + 1);
|
||||
m_model->ProcessEntityUpdates();
|
||||
}
|
||||
|
||||
for (int i = depth - 1; i >= 0; --i)
|
||||
{
|
||||
m_undoStack->Undo();
|
||||
EXPECT_EQ(modelDepth(), i);
|
||||
m_model->ProcessEntityUpdates();
|
||||
}
|
||||
|
||||
for (int i = 0; i < depth; ++i)
|
||||
{
|
||||
m_undoStack->Redo();
|
||||
EXPECT_EQ(modelDepth(), i + 1);
|
||||
m_model->ProcessEntityUpdates();
|
||||
}
|
||||
}
|
||||
} // namespace UnitTest
|
||||
Reference in New Issue
Block a user