diff --git a/novelwriter/core/tree.py b/novelwriter/core/tree.py index 1fcbc54c..7da3e6ac 100644 --- a/novelwriter/core/tree.py +++ b/novelwriter/core/tree.py @@ -26,7 +26,7 @@ from __future__ import annotations import random import logging -from typing import TYPE_CHECKING, Iterator, overload +from typing import TYPE_CHECKING, Iterator, Literal, overload from pathlib import Path from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType @@ -94,19 +94,21 @@ class NWTree: return self._treeOrder.copy() @overload - def create(self, label: str, parent: None, itemType: nwItemType, - itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str: - ... + def create(self, label: str, parent: None, itemType: Literal[nwItemType.ROOT], + itemClass: nwItemClass) -> str: # pragma: no cover + pass @overload def create(self, label: str, parent: str | None, itemType: nwItemType, - itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str | None: - ... + itemClass: nwItemClass = nwItemClass.NO_CLASS) -> str | None: # pragma: no cover + pass def create(self, label, parent, itemType, itemClass=nwItemClass.NO_CLASS): """Create a new item in the project tree, and return its handle. - If the item cannot be added to the project, None is returned. + If the item cannot be added to the project because of an invalid + parent, None is returned. For root elements, this cannot occur. """ + parent = None if itemType == nwItemType.ROOT else parent if parent is None or parent in self._treeOrder: tHandle = self._makeHandle() newItem = NWItem(self._project, tHandle) @@ -238,6 +240,7 @@ class NWTree: newItem.setClass(oClass) newItem.setLayout(oLayout) if self.append(newItem): + self.updateItemData(cHandle) recovered += 1 return orphans, recovered diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index 3ac7b54f..3e0e7b11 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -25,10 +25,10 @@ from shutil import copyfile from zipfile import ZipFile from mocked import causeOSError -from tools import C, cmpFiles, writeFile, buildTestProject, XML_IGNORE +from tools import C, cmpFiles, buildTestProject, XML_IGNORE from novelwriter import CONFIG -from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout +from novelwriter.enum import nwItemClass from novelwriter.constants import nwFiles from novelwriter.core.tree import NWTree from novelwriter.core.index import NWIndex @@ -573,90 +573,6 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): # END Test testCoreProject_Methods -@pytest.mark.core -@pytest.mark.skip -def testCoreProject_OrphanedFiles(mockGUI, prjLipsum): - """Check that files in the content folder that are not tracked in - the project XML file are handled correctly by the orphaned files - function. It should also restore as much meta data as possible from - the meta line at the top of the document file. - """ - theProject = NWProject(mockGUI) - - assert theProject.openProject(prjLipsum) is True - assert theProject.tree["636b6aa9b697b"] is None - - # Add a file with non-existent parent - # This file will be removed from the project on open - oHandle = theProject.newFile("Oops", "b3643d0f92e32") - theProject.tree[oHandle].setParent("1234567890abc") - - # Save and close - assert theProject.saveProject() is True - theProject.closeProject() - - # First Item with Meta Data - orphPath = prjLipsum / "content" / "636b6aa9b697b.nwd" - writeFile(orphPath, ( - "%%~name:[Recovered] Mars\n" - "%%~path:5eaea4e8cdee8/636b6aa9b697b\n" - "%%~kind:WORLD/NOTE\n" - "%%~invalid\n" - "\n" - )) - - # Second Item without Meta Data - orphPath = prjLipsum / "content" / "736b6aa9b697b.nwd" - writeFile(orphPath, "\n") - - # Invalid File Name - tstPath = prjLipsum / "content" / "636b6aa9b697b.txt" - writeFile(tstPath, "\n") - - # Invalid File Name - tstPath = prjLipsum / "content" / "636b6aa9b697bb.nwd" - writeFile(tstPath, "\n") - - # Invalid File Name - tstPath = prjLipsum / "content" / "abcdefghijklm.nwd" - writeFile(tstPath, "\n") - - assert theProject.openProject(prjLipsum) - assert theProject.storage.storagePath is not None - assert theProject.storage.runtimePath is not None - assert theProject.tree["636b6aa9b697bb"] is None - assert theProject.tree["abcdefghijklm"] is None - - # First Item with Meta Data - oItem = theProject.tree["636b6aa9b697b"] - assert oItem is not None - assert oItem.itemName == "[Recovered] Mars" - assert oItem.itemHandle == "636b6aa9b697b" - assert oItem.itemParent == "60bdf227455cc" - assert oItem.itemClass == nwItemClass.WORLD - assert oItem.itemType == nwItemType.FILE - assert oItem.itemLayout == nwItemLayout.NOTE - - # Second Item without Meta Data - oItem = theProject.tree["736b6aa9b697b"] - assert oItem is not None - assert oItem.itemName == "Recovered File 1" - assert oItem.itemHandle == "736b6aa9b697b" - assert oItem.itemParent == "b3643d0f92e32" - assert oItem.itemClass == nwItemClass.NOVEL - assert oItem.itemType == nwItemType.FILE - assert oItem.itemLayout == nwItemLayout.NOTE - - assert theProject.saveProject(prjLipsum) - theProject.closeProject() - - # Finally, check that the orphaned files function returns - # if no project is open and no path is set - assert not theProject._scanProjectFolder() - -# END Test testCoreProject_OrphanedFiles - - @pytest.mark.core def testCoreProject_Backup(monkeypatch, mockGUI, fncPath, tstPaths): """Test the automated backup feature of the project class. The test diff --git a/tests/test_core/test_core_projectxml.py b/tests/test_core/test_core_projectxml.py index 9150e081..3448e653 100644 --- a/tests/test_core/test_core_projectxml.py +++ b/tests/test_core/test_core_projectxml.py @@ -140,6 +140,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath): assert xmlReader.state == XMLReadState.PARSED_OK assert xmlReader.xmlRoot == "novelWriterXML" assert xmlReader.xmlVersion == 0x0105 + assert xmlReader.xmlRevision == 1 assert xmlReader.appVersion == "2.0-rc1" assert xmlReader.hexVersion == 0x020000c1 diff --git a/tests/test_core/test_core_tree.py b/tests/test_core/test_core_tree.py index db382b4f..c2301bf7 100644 --- a/tests/test_core/test_core_tree.py +++ b/tests/test_core/test_core_tree.py @@ -18,12 +18,14 @@ General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ +from __future__ import annotations import pytest import random from pathlib import Path +from tools import C, buildTestProject from mocked import causeOSError from novelwriter.enum import nwItemClass, nwItemType, nwItemLayout @@ -212,6 +214,14 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert theTree.append(itemU) is False assert len(theTree) == len(mockItems) + 1 + # Create a new root, but with a parent set anyway (the parent should be ignored) + zHandle = theTree.create("Custom", "a000000000001", nwItemType.ROOT, nwItemClass.CUSTOM) + assert isinstance(zHandle, str) + itemZ = theTree[zHandle] + assert isinstance(itemZ, NWItem) + assert itemZ.itemParent is None + del theTree[zHandle] + # Duplicate Items # =============== @@ -224,7 +234,7 @@ def testCoreTree_BuildTree(mockGUI, mockItems): assert len(theTree) == len(mockItems) + 2 dHandle = itemV.itemHandle - assert dHandle == "0000000000001" + assert dHandle == "0000000000002" # Delete Items # ============ @@ -285,6 +295,73 @@ def testCoreTree_PackUnpack(mockGUI, mockItems): # END Test testCoreTree_PackUnpack +@pytest.mark.core +def testCoreTree_CheckConsistency(caplog: pytest.LogCaptureFixture, mockGUI, fncPath, mockRnd): + """Check the project consistency.""" + theProject = NWProject(mockGUI) + buildTestProject(theProject, fncPath) + + # By default, all is well + caplog.clear() + assert theProject.tree.checkConsistency("Recovered") == (0, 0) + assert all(m.endswith("OK") for m in caplog.messages) + + # Give the scene file an unknown parent + caplog.clear() + theProject.tree[C.hSceneDoc].setParent(C.hInvalid) # type: ignore + assert theProject.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 = theProject.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 = theProject.storage.contentPath + assert isinstance(contentPath, Path) + assert contentPath == fncPath / "content" + (contentPath / f"{xHandle}.nwd").write_text("### Stuff", encoding="utf-8") + + assert theProject.tree.checkConsistency("Recovered") == (1, 1) + assert xHandle in theProject.tree + itemX = theProject.tree[xHandle] + assert isinstance(itemX, NWItem) + + # It should by default be added as a Novel file + assert itemX.itemParent == C.hNovelRoot + assert itemX.itemRoot == C.hNovelRoot + assert itemX.itemClass == nwItemClass.NOVEL + assert itemX.itemName == "[Recovered] 0123456789abc" + + # Set an unknown class in the orphaned item + itemX.setClass(nwItemClass.OBJECT) + itemX.setName("Stuff") + itemX.setParent(C.hInvalid) + theProject.storage.getDocument(xHandle).writeDocument("### Stuff") # This adds meta data + + # Remove the item in the project, and re-run the consistency check + del theProject.tree[xHandle] + assert theProject.tree.checkConsistency("Recovered") == (1, 1) + assert xHandle in theProject.tree + itemX = theProject.tree[xHandle] + assert isinstance(itemX, NWItem) + + # It should again be added as a Novel file + assert itemX.itemParent == C.hNovelRoot + assert itemX.itemRoot == C.hNovelRoot + assert itemX.itemClass == nwItemClass.NOVEL + assert itemX.itemName == "[Recovered] Stuff" + + # If the tree is empty, there is nowhere to add any of the 4 files + theProject.tree.clear() + assert theProject.tree.checkConsistency("Recovered") == (4, 0) + assert len(theProject.tree) == 0 + +# END Test testCoreTree_CheckConsistency + + @pytest.mark.core def testCoreTree_Methods(mockGUI, mockItems): """Test various class methods."""