diff --git a/novelwriter/core/itemmodel.py b/novelwriter/core/itemmodel.py index 25a58796..c83e7907 100644 --- a/novelwriter/core/itemmodel.py +++ b/novelwriter/core/itemmodel.py @@ -66,6 +66,25 @@ if TYPE_CHECKING: # pragma: no cover class ProjectNode: + """Core: Project Model Node Class + + The project tree structure is saved as nodes in a tree, starting + from a root node. This class makes up these nodes. + + Each node is a wrapper around an NWItem object. The NWItem is the + object representing a single item in the project, and it only + contains a reference to its parent as well as it top level root, but + is itself not structured in a hierarchy in memory. + + This class provides the necessary hierarchical structure, as well as + the data entries needed for populating the GUI project tree. It also + handles pushing and pulling information from its NWItem when + necessary. + + The data to be displayed could in principle be pulled from the + NWItem whenever it is needed, but for performance reason it is + cached, as the GUI will pull this information often. + """ C_NAME = 0 C_COUNT = 1 @@ -254,6 +273,13 @@ class ProjectNode: class ProjectModel(QAbstractItemModel): + """Core: Project Model Class + + This class provides the interface for the tree widget used on the + GUI. It implements the QModelIndex based interface required, adds + support for drag and drop, and a few other novelWriter-specific + methods needed primarily by the project tree GUI component. + """ __slots__ = ("_tree", "_root") diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index c2afe769..55c23ebd 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -45,7 +45,7 @@ if TYPE_CHECKING: # pragma: no cover logger = logging.getLogger(__name__) -MAX_DEPTH = 1000 # Cap of tree traversing for loops (recursion limit) +MAX_DEPTH = 999 # Cap of tree traversing for loops (recursion limit) class NWTree: @@ -55,12 +55,6 @@ class NWTree: This class holds all the project items of the project as instances of NWItem. - For historical reasons, the order of the items is saved in a - separate list from the items themselves, which are stored in a - dictionary. This is somewhat redundant with the newer versions of - Python, but is still practical as it's easier to update the item - order as a list. - Each item has a handle, which is a random hex string of length 13. The handle is the name of the item everywhere in novelWriter, and is also used for file names. @@ -77,10 +71,37 @@ class NWTree: logger.debug("Ready: NWTree") return - def __del__(self) -> None: + def __del__(self) -> None: # pragma: no cover logger.debug("Delete: NWTree") return + def __len__(self) -> int: + """The number of items in the project.""" + return len(self._items) + + def __bool__(self) -> bool: + """True if there are any items in the project.""" + return bool(self._items) + + def __getitem__(self, tHandle: str | None) -> NWItem | None: + """Return a project item based on its handle. Returns None if + the handle doesn't exist in the project. + """ + if tHandle and tHandle in self._items: + return self._items[tHandle] + logger.error("No tree item with handle '%s'", str(tHandle)) + return None + + def __contains__(self, tHandle: str) -> bool: + """Checks if a handle exists in the tree.""" + return tHandle in self._items + + def __iter__(self) -> Iterator[NWItem]: + """Iterate through project items.""" + for node in self._model.root.allChildren(): + yield node.item + return + ## # Properties ## @@ -148,7 +169,8 @@ class NWTree: self._itemChange(node.item, nwChange.DELETE) del self._nodes[tHandle] del self._items[tHandle] - return True + return True + return False @overload # pragma: no cover def create( @@ -219,7 +241,7 @@ class NWTree: later = items self._model.beginInsertRows(self._model.index(0, 0), 0, 0) - for _ in range(999): + for _ in range(MAX_DEPTH): later = self._addItems(later) if len(later) == 0: break @@ -251,6 +273,8 @@ class NWTree: for node in reversed(self._model.root.allChildren()): node.refresh() node.updateCount(propagate=False) + self._model.root.refresh() + self._model.root.updateCount(propagate=False) self._model.layoutChanged.emit() return @@ -364,35 +388,27 @@ class NWTree: def checkType(self, tHandle: str, itemType: nwItemType) -> bool: """Check if item exists and is of the specified item type.""" - tItem = self.__getitem__(tHandle) - if not tItem: - return False - return tItem.itemType == itemType + if tItem := self.__getitem__(tHandle): + return tItem.itemType == itemType + return False - def getItemPath(self, tHandle: str, asName: bool = False) -> list[str]: + def itemPath(self, tHandle: str, asName: bool = False) -> list[str]: """Iterate upwards in the tree until we find the item with parent None, the root item, and return the list of handles, or alternatively item names. We do this with a for loop with a maximum depth to make infinite loops impossible. """ - tTree = [] - tItem = self.__getitem__(tHandle) - if tItem is not None: - tTree.append(tItem.itemName if asName else tHandle) + path = [] + if node := self._nodes.get(tHandle): for _ in range(MAX_DEPTH): - if tItem.itemParent is None: - return tTree + if parent := node.parent(): + path.append(node.item.itemName if asName else tHandle) + node = parent else: - tHandle = tItem.itemParent - tItem = self.__getitem__(tHandle) - if tItem is None: - return tTree - else: - tTree.append(tItem.itemName if asName else tHandle) + return path else: - raise RecursionError("Critical internal error") - - return tTree + logger.error("Max project tree depth reached") + return path def subTree(self, tHandle: str) -> list[str]: """Get the subtree from a given handle.""" @@ -426,37 +442,6 @@ class NWTree: return node.item.itemHandle return None - ## - # Special Methods - ## - - def __len__(self) -> int: - """The number of items in the project.""" - return len(self._items) - - def __bool__(self) -> bool: - """True if there are any items in the project.""" - return bool(self._items) - - def __getitem__(self, tHandle: str | None) -> NWItem | None: - """Return a project item based on its handle. Returns None if - the handle doesn't exist in the project. - """ - if tHandle and tHandle in self._items: - return self._items[tHandle] - logger.error("No tree item with handle '%s'", str(tHandle)) - return None - - def __contains__(self, tHandle: str) -> bool: - """Checks if a handle exists in the tree.""" - return tHandle in self._items - - def __iter__(self) -> Iterator[NWItem]: - """Iterate through project items.""" - for node in self._model.root.allChildren(): - yield node.item - return - ## # Internal Functions ## diff --git a/novelwriter/gui/doceditor.py b/novelwriter/gui/doceditor.py index 5a5a6ba3..a77869ca 100644 --- a/novelwriter/gui/doceditor.py +++ b/novelwriter/gui/doceditor.py @@ -3013,7 +3013,7 @@ class GuiDocEditHeader(QWidget): if CONFIG.showFullPath: self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed( - [name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)] + [name for name in SHARED.project.tree.itemPath(tHandle, asName=True)] ))) else: self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "") diff --git a/novelwriter/gui/docviewer.py b/novelwriter/gui/docviewer.py index 735c15e1..9e1b6f5f 100644 --- a/novelwriter/gui/docviewer.py +++ b/novelwriter/gui/docviewer.py @@ -806,7 +806,7 @@ class GuiDocViewHeader(QWidget): if CONFIG.showFullPath: self.itemTitle.setText(f" {nwUnicode.U_RSAQUO} ".join(reversed( - [name for name in SHARED.project.tree.getItemPath(tHandle, asName=True)] + [name for name in SHARED.project.tree.itemPath(tHandle, asName=True)] ))) else: self.itemTitle.setText(i.itemName if (i := SHARED.project.tree[tHandle]) else "") diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index b76d20a1..baf6d3e4 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -22,282 +22,311 @@ from __future__ import annotations import random +from copy import deepcopy from pathlib import Path import pytest -from novelwriter.common import isHandle from novelwriter.constants import nwFiles from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject from novelwriter.core.tree import NWTree -from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType +from novelwriter.enum import nwItemClass, nwItemType from tests.mocked import causeOSError from tests.tools import C, buildTestProject @pytest.fixture(scope="function") -def mockItems(mockGUI, mockRnd): +def mockItems(mockGUI, mockRnd, fncPath): """Create a list of mock items.""" project = NWProject() - - itemA = NWItem(project, "a000000000001") - itemA._name = "Novel" - itemA._parent = None - itemA._type = nwItemType.ROOT - itemA._class = nwItemClass.NOVEL - itemA._expanded = True - - itemB = NWItem(project, "b000000000001") - itemB._name = "Act One" - itemB._parent = "a000000000001" - itemB._type = nwItemType.FOLDER - itemB._class = nwItemClass.NOVEL - itemB._expanded = True - - itemC = NWItem(project, "c000000000001") - itemC._name = "Chapter One" - itemC._parent = "b000000000001" - itemC._type = nwItemType.FILE - itemC._class = nwItemClass.NOVEL - itemC._layout = nwItemLayout.DOCUMENT - itemC._charCount = 300 - itemC._wordCount = 50 - itemC._paraCount = 2 - - itemD = NWItem(project, "c000000000002") - itemD._name = "Scene One" - itemD._parent = "b000000000001" - itemD._type = nwItemType.FILE - itemD._class = nwItemClass.NOVEL - itemD._layout = nwItemLayout.DOCUMENT - itemD._charCount = 3000 - itemD._wordCount = 500 - itemD._paraCount = 20 - - itemE = NWItem(project, "a000000000002") - itemE._name = "Outtakes" - itemE._parent = None - itemE._type = nwItemType.ROOT - itemE._class = nwItemClass.ARCHIVE - itemE._expanded = False - - itemF = NWItem(project, "a000000000003") - itemF._name = "Trash" - itemF._parent = None - itemF._type = nwItemType.ROOT - itemF._class = nwItemClass.TRASH - itemF._expanded = False - - itemG = NWItem(project, "a000000000004") - itemG._name = "Characters" - itemG._parent = None - itemG._type = nwItemType.ROOT - itemG._class = nwItemClass.CHARACTER - itemG._expanded = True - - itemH = NWItem(project, "b000000000002") - itemH._name = "Jane Doe" - itemH._parent = "a000000000004" - itemH._type = nwItemType.FILE - itemH._class = nwItemClass.CHARACTER - itemH._layout = nwItemLayout.NOTE - itemH._charCount = 2000 - itemH._wordCount = 400 - itemH._paraCount = 16 - - return [itemA, itemB, itemC, itemD, itemE, itemF, itemG, itemH] + mockRnd.reset() + buildTestProject(project, fncPath) + return project.tree.pack() @pytest.mark.core -@pytest.mark.skip -def testCoreTree_BuildTree(mockGUI, mockItems): - """Test building a project tree from a list of items.""" +def testCoreTree_Populate(monkeypatch, mockGUI, mockItems): + """Test populating the project tree.""" project = NWProject() tree = NWTree(project) - # Check that tree is empty (calls NWTree.__bool__) + # Check that tree is empty assert bool(tree) is False + assert len(tree) == 0 - # Check for archive and trash folders - assert tree.trashRoot is None - - aHandles = [] - for nwItem in mockItems: - aHandles.append(nwItem.itemHandle) - assert tree.append(nwItem) is True - assert tree.updateItemData(nwItem.itemHandle) is True - - assert tree._changed is True - - # Check that tree is not empty (calls __bool__) + # Trash should be added on request + assert tree.trash is not None + assert tree.nodes[tree.trash.item.itemHandle].item.itemName == "Trash" assert bool(tree) is True + assert len(tree) == 1 + tree.clear() - # Check the number of elements (calls __len__) - assert len(tree) == len(mockItems) + # Load Items + tree.unpack(mockItems) + assert len(tree) == 9 + assert tree.trash is not None + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Trash", + ] - # Check that we have the correct handles - assert tree.handles() == aHandles + # Pack the data again + assert [n["name"] for n in tree.pack()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Trash", + ] - # Check by iterator (calls __iter__, __next__ and __getitem__) - for item, handle in zip(tree, aHandles): - assert item.itemHandle == handle + # Inject a node in the map, but not in the tree (inconsistent tree) + # This should be ignored + tree._nodes["123456789abc"] = tree.model.root + assert [n["name"] for n in tree.pack()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Trash", + ] - # Trash Folder - # ============ + # Clear, and populate in reverse order + tree.clear() + assert bool(tree) is False + assert len(tree) == 0 + tree.unpack(list(reversed(mockItems))) + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Locations", "Characters", "Plot", "Novel", "New Folder", + "New Scene", "New Chapter", "Title Page", "Trash", + ] - # Check that we have the correct archive and trash folders - assert tree.trashRoot == "a000000000003" - assert tree.findRoot(nwItemClass.ARCHIVE) == "a000000000002" - assert tree.isTrash("a000000000003") is True + # Clear, and populate with one item being its own parent + tree.clear() + assert bool(tree) is False + assert len(tree) == 0 + modItems = deepcopy(mockItems) + assert modItems[1]["name"] == "Title Page" + modItems[1]["itemAttr"]["parent"] = modItems[1]["itemAttr"]["handle"] + tree.unpack(mockItems) + assert len(tree) == 9 + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Trash", + ] - # Check that we have the root classes - assert tree.rootClasses() == { - nwItemClass.NOVEL, nwItemClass.CHARACTER, nwItemClass.ARCHIVE, nwItemClass.TRASH - } - - # Check the isTrash function - assert tree.isTrash("0000000000000") is True # Doesn't exist - assert tree.isTrash("a000000000003") is True # This the trash folder - - tree["a000000000003"].setClass(nwItemClass.NO_CLASS) # type: ignore - assert tree.isTrash("a000000000003") is True # This is still trash - tree["a000000000003"].setClass(nwItemClass.TRASH) # type: ignore - - assert tree.isTrash("b000000000002") is False # This is not trash - - value = tree["b000000000002"].itemParent # type: ignore - tree["b000000000002"].setParent("a000000000003") # type: ignore - assert tree.isTrash("b000000000002") is True # This is in trash - tree["b000000000002"].setParent(value) # type: ignore - - value = tree["b000000000002"].itemRoot # type: ignore - tree["b000000000002"].setRoot("a000000000003") # type: ignore - assert tree.isTrash("b000000000002") is True # This is in trash - tree["b000000000002"].setRoot(value) # type: ignore - - # Try to add another trash folder - itemT = NWItem(project, "1111111111111") - itemT._name = "Trash" - itemT._type = nwItemType.ROOT - itemT._class = nwItemClass.TRASH - itemT._expanded = False - - assert tree.append(itemT) is False - assert len(tree) == len(mockItems) - - # Create or Add Items - # =================== - - # Create a new item, but with invalid parent - assert tree.create("New File", "blabla", nwItemType.FILE, nwItemClass.NO_CLASS) is None - - # Create a new, valid item - nHandle = tree.create("New File", "b000000000001", nwItemType.FILE, nwItemClass.NO_CLASS) - assert isHandle(nHandle) - assert nHandle == "0000000000000" - - # The new item should be the last item in the tree - handles = tree.handles() - assert handles[-1] == nHandle - - # Retrieve the item - itemT = tree[nHandle] - assert isinstance(itemT, NWItem) - assert len(tree) == len(mockItems) + 1 - - # We should not be allowed to add the item again - assert tree.append(itemT) is False - assert len(tree) == len(mockItems) + 1 - - # Create an invalid item to add, which will be rejected - itemU = NWItem.duplicate(itemT, "blabla") - assert tree.append(itemU) is False - assert len(tree) == len(mockItems) + 1 - - # Create a new root, but with a parent set anyway (the parent should be ignored) - zHandle = tree.create("Custom", "a000000000001", nwItemType.ROOT, nwItemClass.CUSTOM) - assert isinstance(zHandle, str) - itemZ = tree[zHandle] - assert isinstance(itemZ, NWItem) - assert itemZ.itemParent is None - del tree[zHandle] - - # Duplicate Items - # =============== - - # Duplicate a non-existing item - assert tree.duplicate("blabla") is None - - # Duplicate the new item - itemV = tree.duplicate(nHandle) - assert isinstance(itemV, NWItem) - assert len(tree) == len(mockItems) + 2 - - dHandle = itemV.itemHandle - assert dHandle == "0000000000002" - - # Delete Items - # ============ - - # Delete a non-existing item - del tree["stuff"] - assert len(tree) == len(mockItems) + 2 - - # Delete the last items - del tree[nHandle] - del tree[dHandle] - assert len(tree) == len(mockItems) - assert nHandle not in tree - - # Delete the Novel, Archive and Trash folders - del tree["a000000000001"] - assert len(tree) == len(mockItems) - 1 - assert "a000000000001" not in tree - - del tree["a000000000002"] - assert len(tree) == len(mockItems) - 2 - assert "a000000000002" not in tree - - del tree["a000000000003"] - assert len(tree) == len(mockItems) - 3 - assert "a000000000003" not in tree - assert tree.trashRoot is None + # Clear and populate reversed with max depth limit very low + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.tree.MAX_DEPTH", 1) + tree.clear() + assert bool(tree) is False + assert len(tree) == 0 + tree.unpack(list(reversed(mockItems))) + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Locations", "Characters", "Plot", "Novel", "Trash", + ] @pytest.mark.core -@pytest.mark.skip -def testCoreTree_PackUnpack(mockGUI, mockItems): - """Test packing and unpacking data.""" +def testCoreTree_ManipulateTree(mockGUI, mockItems): + """Check create, add, remove and duplicate items.""" project = NWProject() tree = NWTree(project) + tree.unpack(mockItems) + assert len(tree) == 9 + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Trash", + ] - aHandles = [] - for nwItem in mockItems: - aHandles.append(nwItem.itemHandle) - tree.append(nwItem) - tree.updateItemData(nwItem.itemHandle) + # Create Root + oHandle = tree.create("Objects", None, nwItemType.ROOT, nwItemClass.OBJECT, pos=4) + assert oHandle is not None + assert oHandle in tree - assert len(tree) == len(mockItems) + # Create Folder + fHandle = tree.create("Foo", oHandle, nwItemType.FOLDER, pos=0) + assert fHandle is not None + assert fHandle in tree - # Pack - packed = tree.pack() - for i, nwItem in enumerate(mockItems): - assert packed[i]["itemAttr"]["handle"] == nwItem.itemHandle + # Create File + bHandle = tree.create("Bar", fHandle, nwItemType.FILE, pos=0) + assert bHandle is not None + assert bHandle in tree - # Unpack - tree.clear() - assert len(tree) == 0 - assert tree.handles() == [] - tree.unpack(packed) - assert tree.handles() == aHandles + # Check Tree + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash", + ] + + # Cannot add a folder or file with no parent + assert tree.create("Foo", None, nwItemType.FOLDER, pos=0) is None + assert tree.create("Bar", None, nwItemType.FILE, pos=0) is None + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash", + ] + + # Duplicate Bar -> Baz + baz = tree.duplicate(bHandle, fHandle, True) + assert baz is not None + baz.setName("Baz") + zHandle = baz.itemHandle + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Baz", "Trash", + ] + + # Duplicate non-existent + assert tree.duplicate("bob", fHandle, True) is None + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Baz", "Trash", + ] + + # Remove Baz + assert tree.remove(zHandle) is True + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash", + ] + assert len(tree._items) == len(tree.model.root.allChildren()) + assert len(tree._items) == len(tree._nodes) + + # Remove non-existing + assert tree.remove("bob") is False + + # Add item with non-existing parent + item = NWItem(project, tree._makeHandle()) + item.setParent(tree._makeHandle()) + assert tree.add(item) is False + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash", + ] + + # Add item with non parent, that isn't a root + item = NWItem(project, tree._makeHandle()) + item.setType(nwItemType.FOLDER) + assert tree.add(item) is False + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Objects", "Foo", "Bar", "Trash", + ] @pytest.mark.core -@pytest.mark.skip -def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fncPath, mockRnd): - """Check the project consistency.""" +def testCoreTree_ItemMethods(monkeypatch, mockGUI, mockItems): + """Check the item methods of the tree.""" + project = NWProject() + tree = NWTree(project) + tree.unpack(mockItems) + assert len(tree) == 9 + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Trash", + ] + + # Check Type + assert tree.checkType(C.hNovelRoot, nwItemType.ROOT) is True + assert tree.checkType(C.hNovelRoot, nwItemType.FOLDER) is False + assert tree.checkType(C.hNovelRoot, nwItemType.FILE) is False + + assert tree.checkType(C.hChapterDir, nwItemType.ROOT) is False + assert tree.checkType(C.hChapterDir, nwItemType.FOLDER) is True + assert tree.checkType(C.hChapterDir, nwItemType.FILE) is False + + assert tree.checkType(C.hChapterDoc, nwItemType.ROOT) is False + assert tree.checkType(C.hChapterDoc, nwItemType.FOLDER) is False + assert tree.checkType(C.hChapterDoc, nwItemType.FILE) is True + + assert tree.checkType(C.hInvalid, nwItemType.ROOT) is False + assert tree.checkType(C.hInvalid, nwItemType.FOLDER) is False + assert tree.checkType(C.hInvalid, nwItemType.FILE) is False + + # Item Path + assert tree.itemPath(C.hSceneDoc, asName=True) == ["New Scene", "New Folder", "Novel"] + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.tree.MAX_DEPTH", 1) + assert tree.itemPath(C.hSceneDoc, asName=True) == ["New Scene"] + + # Sub Tree + assert tree.subTree(C.hInvalid) == [] + assert tree.subTree(C.hNovelRoot) == [ + C.hTitlePage, C.hChapterDir, C.hChapterDoc, C.hSceneDoc, + ] + + # Root Classes + classes = tree.rootClasses() + assert len(classes) == 5 + assert nwItemClass.NOVEL in classes + assert nwItemClass.PLOT in classes + assert nwItemClass.CHARACTER in classes + assert nwItemClass.WORLD in classes + assert nwItemClass.TIMELINE not in classes + assert nwItemClass.OBJECT not in classes + assert nwItemClass.ENTITY not in classes + assert nwItemClass.CUSTOM not in classes + assert nwItemClass.ARCHIVE not in classes + assert nwItemClass.TEMPLATE not in classes + assert nwItemClass.TRASH in classes + + # Iter Roots + assert list(tree.iterRoots(nwItemClass.NOVEL)) == [(C.hNovelRoot, tree[C.hNovelRoot])] + assert list(tree.iterRoots(nwItemClass.PLOT)) == [(C.hPlotRoot, tree[C.hPlotRoot])] + assert list(tree.iterRoots(nwItemClass.CHARACTER)) == [(C.hCharRoot, tree[C.hCharRoot])] + assert list(tree.iterRoots(nwItemClass.WORLD)) == [(C.hWorldRoot, tree[C.hWorldRoot])] + assert list(tree.iterRoots(nwItemClass.OBJECT)) == [] + + # Find Root + assert tree.findRoot(nwItemClass.NOVEL) == C.hNovelRoot + assert tree.findRoot(nwItemClass.PLOT) == C.hPlotRoot + assert tree.findRoot(nwItemClass.CHARACTER) == C.hCharRoot + assert tree.findRoot(nwItemClass.WORLD) == C.hWorldRoot + assert tree.findRoot(nwItemClass.OBJECT) is None + + +@pytest.mark.core +def testCoreTree_OtherMethods(qtbot, monkeypatch, mockGUI, fncPath, mockRnd): + """Check other methods in the tree.""" + project = NWProject() + buildTestProject(project, fncPath) + tree = project.tree + trash = tree.trash + + assert len(tree) == 9 + assert [n.item.itemName for n in tree.model.root.allChildren()] == [ + "Novel", "Title Page", "New Folder", "New Chapter", "New Scene", + "Plot", "Characters", "Locations", "Trash", + ] + + # Refresh All + assert tree.sumWords() == (9, 0) + assert tree.model.root.count == 9 + + for node in tree.nodes.values(): + if node.item.isFileType(): + node.item.setWordCount(5) + + with qtbot.waitSignal(tree._model.layoutChanged): + tree.refreshAllItems() + assert tree.model.root.count == 15 + + project.index.rebuild() + tree.refreshAllItems() + assert tree.model.root.count == 9 + + # Trash can't be created + assert trash is not None + assert tree._getTrashNode() is trash + tree._trash = None + assert tree._getTrashNode() is trash + tree.remove(trash.item.itemHandle) + + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.tree.NWTree.create", lambda *a, **k: None) + assert tree._getTrashNode() is None + + +@pytest.mark.core +def testCoreTree_CheckConsistency(caplog, mockGUI, fncPath, mockRnd): + """Check the project tree's consistency.""" project = NWProject() buildTestProject(project, fncPath) @@ -306,17 +335,6 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc assert project.tree.checkConsistency("Recovered") == (0, 0) assert all(m.endswith("OK") for m in caplog.messages) - # Give the scene file an unknown parent - caplog.clear() - project.tree[C.hSceneDoc].setParent(C.hInvalid) # type: ignore - assert project.tree.checkConsistency("Recovered") == (1, 1) - assert f"'{C.hSceneDoc}' ... ERROR" in caplog.text - - # The scene file should have been added back to its home - itemS = project.tree[C.hSceneDoc] - assert isinstance(itemS, NWItem) - assert itemS.itemParent == C.hChapterDir - # Create a new file with no meta data, and let the function handle it as orphaned xHandle = "0123456789abc" contentPath = project.storage.contentPath @@ -342,7 +360,7 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc project.storage.getDocument(xHandle).writeDocument("### Stuff") # This adds meta data # Remove the item in the project, and re-run the consistency check - del project.tree[xHandle] + project.tree.remove(xHandle) assert project.tree.checkConsistency("Recovered") == (1, 1) assert xHandle in project.tree itemX = project.tree[xHandle] @@ -363,87 +381,6 @@ def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fnc @pytest.mark.core -@pytest.mark.skip -def testCoreTree_Methods(monkeypatch, mockGUI, mockItems): - """Test various class methods.""" - project = NWProject() - tree = NWTree(project) - - for nwItem in mockItems: - tree.append(nwItem) - tree.updateItemData(nwItem.itemHandle) - - assert len(tree) == len(mockItems) - - # Update item data, nonsense handle - assert tree.updateItemData("stuff") is False - - # Update item data, invalid item parent - corrParent = tree["b000000000001"].itemParent # type: ignore - tree["b000000000001"].setParent("0000000000000") # type: ignore - assert tree.updateItemData("b000000000001") is False - - # Update item data, valid item parent - tree["b000000000001"].setParent(corrParent) # type: ignore - assert tree.updateItemData("b000000000001") is True - - # Update item data, root is unreachable - with monkeypatch.context() as mp: - mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0) - with pytest.raises(RecursionError): - tree.updateItemData("b000000000001") - - # Check type - assert tree.checkType("blabla", nwItemType.FILE) is False - assert tree.checkType("b000000000001", nwItemType.FILE) is False - assert tree.checkType("c000000000001", nwItemType.FILE) is True - - # Root item lookup - assert tree.findRoot(nwItemClass.WORLD) is None - assert tree.findRoot(nwItemClass.NOVEL) == "a000000000001" - assert tree.findRoot(nwItemClass.CHARACTER) == "a000000000004" - - # Iter roots - roots = list(tree.iterRoots(None)) - assert roots[0][0] == "a000000000001" - assert roots[1][0] == "a000000000002" - assert roots[2][0] == "a000000000003" - assert roots[3][0] == "a000000000004" - - # Add a fake item to root and check that it can handle it - tree._roots["0000000000000"] = NWItem(project, "0000000000000") - assert tree.findRoot(nwItemClass.WORLD) is None - del tree._roots["0000000000000"] - - # Get item path - assert tree.getItemPath("stuff") == [] - assert tree.getItemPath("c000000000001") == [ - "c000000000001", "b000000000001", "a000000000001" - ] - assert tree.getItemPath("c000000000001", asName=True) == [ - "Chapter One", "Act One", "Novel" - ] - - # Cause recursion error - with monkeypatch.context() as mp: - mp.setattr("novelwriter.core.tree.MAX_DEPTH", 0) - with pytest.raises(RecursionError): - tree.getItemPath("c000000000001") - - # Break the folder parent handle - tree["b000000000001"]._parent = "stuff" # type: ignore - assert tree.getItemPath("c000000000001") == [ - "c000000000001", "b000000000001" - ] - - tree["b000000000001"]._parent = "a000000000001" # type: ignore - assert tree.getItemPath("c000000000001") == [ - "c000000000001", "b000000000001", "a000000000001" - ] - - -@pytest.mark.core -@pytest.mark.skip def testCoreTree_MakeHandles(mockGUI): """Test generating item handles.""" random.seed(42) @@ -455,15 +392,15 @@ def testCoreTree_MakeHandles(mockGUI): random.seed(42) tHandle = tree._makeHandle() assert tHandle == handles[0] - tree._tree[handles[0]] = None # type: ignore + tree._items[handles[0]] = None # type: ignore # Add the next in line to the project to force duplicate - tree._tree[handles[1]] = None # type: ignore + tree._items[handles[1]] = None # type: ignore tHandle = tree._makeHandle() assert tHandle == handles[2] - tree._tree[handles[2]] = None # type: ignore + tree._items[handles[2]] = None # type: ignore - # Reset the seed to force collissions, which should still end up + # Reset the seed to force collisions, which should still end up # returning the next handle in the sequence random.seed(42) tHandle = tree._makeHandle() @@ -471,71 +408,11 @@ def testCoreTree_MakeHandles(mockGUI): @pytest.mark.core -@pytest.mark.skip -def testCoreTree_Stats(mockGUI, mockItems): - """Test project stats methods.""" - project = NWProject() - tree = NWTree(project) - - for nwItem in mockItems: - tree.append(nwItem) - - assert len(tree) == len(mockItems) - tree._order.append("stuff") - - # Count Words - novelWords, noteWords = tree.sumWords() - assert novelWords == 550 - assert noteWords == 400 - - -@pytest.mark.core -@pytest.mark.skip -def testCoreTree_Reorder(caplog, mockGUI, mockItems): - """Test changing tree order.""" - project = NWProject() - tree = NWTree(project) - - aHandle = [] - for nwItem in mockItems: - aHandle.append(nwItem.itemHandle) - tree.append(nwItem) - - assert len(tree) == len(mockItems) - - bHandle = aHandle.copy() - bHandle[2], bHandle[3] = bHandle[3], bHandle[2] - assert aHandle != bHandle - - assert tree.handles() == aHandle - tree.setOrder(bHandle) - assert tree.handles() == bHandle - - caplog.clear() - tree.setOrder(bHandle + ["stuff"]) - assert tree.handles() == bHandle - assert "Handle 'stuff' in new tree order is not in old order" in caplog.text - - caplog.clear() - tree._order.append("stuff") - tree.setOrder(bHandle) - assert tree.handles() == bHandle - assert "Handle 'stuff' in old tree order is not in new order" in caplog.text - - -@pytest.mark.core -@pytest.mark.skip def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems): """Test writing the ToC.txt file.""" project = NWProject() tree = NWTree(project) - - for nwItem in mockItems: - tree.append(nwItem) - tree.updateItemData(nwItem.itemHandle) - - assert len(tree) == len(mockItems) - tree._order.append("stuff") + tree.unpack(mockItems) def mockIsFile(fileName): """Return True for items that are files in novelWriter and @@ -547,7 +424,7 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems): monkeypatch.setattr("pathlib.Path.is_file", mockIsFile) project._storage._runtimePath = fncPath - (fncPath / "content").mkdir() + # (fncPath / "content").mkdir() # Block extraction of the path with monkeypatch.context() as mp: @@ -561,11 +438,6 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems): # Allow writing assert tree.writeToCFile() is True - - pathA = str(Path("content") / "c000000000001.nwd") - pathB = str(Path("content") / "c000000000002.nwd") - pathC = str(Path("content") / "b000000000002.nwd") - assert (fncPath / nwFiles.TOC_TXT).read_text() == ( "\n" "Table of Contents\n" @@ -573,7 +445,7 @@ def testCoreTree_ToCFile(monkeypatch, fncPath, mockGUI, mockItems): "\n" "File Name Class Layout Document Label\n" "--------------------------------------------------------------\n" - f"{pathA} NOVEL DOCUMENT Chapter One\n" - f"{pathB} NOVEL DOCUMENT Scene One\n" - f"{pathC} CHARACTER NOTE Jane Doe\n" + "content/000000000000c.nwd NOVEL DOCUMENT Title Page\n" + "content/000000000000e.nwd NOVEL DOCUMENT New Chapter\n" + "content/000000000000f.nwd NOVEL DOCUMENT New Scene\n" )