Add test coverage of the project tree

This commit is contained in:
Veronica Berglyd Olsen
2024-11-24 02:48:17 +01:00
parent 01b2b53fd5
commit 9ec9c55c3f
5 changed files with 346 additions and 463 deletions
+26
View File
@@ -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")
+46 -61
View File
@@ -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
##
+1 -1
View File
@@ -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 "")
+1 -1
View File
@@ -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 "")
+272 -400
View File
@@ -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"
)