From f5a3280565799b53439f0615283eb625b0809f4d Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 8 Nov 2022 22:19:22 +0100 Subject: [PATCH] Add test coverage of storage class --- novelwriter/core/project.py | 2 + novelwriter/core/storage.py | 10 +- tests/conftest.py | 21 +- tests/test_core/test_core_storage.py | 332 +++++++++++++++++++++++++++ 4 files changed, 349 insertions(+), 16 deletions(-) create mode 100644 tests/test_core/test_core_storage.py diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index 65d292c8..86c14761 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -422,6 +422,7 @@ class NWProject(QObject): # Save other project data self._options.saveSettings() self._index.saveIndex() + self._storage.runPostSaveTasks(autoSave=autoSave) # Update recent projects self.mainConf.updateRecentCache( @@ -443,6 +444,7 @@ class NWProject(QObject): self._tree.writeToCFile() self._appendSessionStats(idleTime) self._storage.clearLockFile() + self._storage.closeSession() self.clearProject() self.lockedBy = None return True diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index 40751909..9b28f31a 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -113,10 +113,10 @@ class NWStorage: return True - def openProjectArchive(self, path): + def openProjectArchive(self, path): # pragma: no cover pass - def runPostSaveTasks(self, autoSave=False): + def runPostSaveTasks(self, autoSave=False): # pragma: no cover """Run tasks after the project has been saved. """ if self._openMode == self.MODE_INPLACE: @@ -295,9 +295,8 @@ class NWStorage: if newProject: # If it's a new project, we check that there is no existing # project in the selected path. - projFile = path / nwFiles.PROJ_FILE - if projFile.exists(): - logger.error("A project already exists in this path") + if path.exists() and len(list(path.iterdir())) > 0: + logger.error("The new project folder is not empty") self.clear() return False @@ -310,6 +309,7 @@ class NWStorage: (path / "meta").mkdir(exist_ok=True) except Exception as exc: logger.error("Failed to create required project folders", exc_info=exc) + self.clear() return False if not checkLegacy: diff --git a/tests/conftest.py b/tests/conftest.py index bcaa2ea3..605393e6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,7 +29,7 @@ from pathlib import Path from mock import MockGuiMain from tools import cleanProject -sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir))) +sys.path.insert(1, str(Path(__file__).parent.parent.absolute())) import novelwriter # noqa: E402 @@ -64,15 +64,15 @@ def tmpDir(): return theDir -@pytest.fixture(scope="function") +@pytest.fixture(scope="session") def tmpPath(tmpDir): - """A temporary folder for a single test function. + """A temporary folder for the test session. Path version. """ return Path(tmpDir) @pytest.fixture(scope="session") -def tstPaths(tmpDir): +def tstPaths(tmpPath): """Returns an object that can provide the various paths needed for running tests. """ @@ -80,7 +80,7 @@ def tstPaths(tmpDir): testDir = Path(__file__).parent filesDir = testDir / "files" refDir = testDir / "reference" - outDir = testDir / tmpDir / "results" + outDir = tmpPath / "results" store = _Store() store.outDir.mkdir(exist_ok=True) @@ -89,14 +89,13 @@ def tstPaths(tmpDir): @pytest.fixture(scope="function") -def fncPath(tmpDir): - """A temporary folder for a single test function. +def fncPath(tmpPath): + """A temporary folder for a single test function. Path version. """ - fncPath = Path(tmpDir) / "f_temp" + fncPath = tmpPath / "function" if fncPath.is_dir(): shutil.rmtree(fncPath) - if not fncPath.is_dir(): - fncPath.mkdir() + fncPath.mkdir(exist_ok=True) return fncPath @@ -133,7 +132,7 @@ def outDir(tmpDir): def fncDir(tmpDir): """A temporary folder for a single test function. """ - fncDir = os.path.join(tmpDir, "f_temp") + fncDir = os.path.join(tmpDir, "function") if os.path.isdir(fncDir): shutil.rmtree(fncDir) if not os.path.isdir(fncDir): diff --git a/tests/test_core/test_core_storage.py b/tests/test_core/test_core_storage.py new file mode 100644 index 00000000..7f986185 --- /dev/null +++ b/tests/test_core/test_core_storage.py @@ -0,0 +1,332 @@ +""" +novelWriter – NWStorage Class Tester +==================================== + +This file is a part of novelWriter +Copyright 2018–2022, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +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 zipfile import ZipFile +import pytest + +from mock import causeOSError +from tools import C, buildTestProject, writeFile + +from novelwriter.constants import nwFiles +from novelwriter.core.project import NWProject +from novelwriter.core.storage import NWStorage +from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter + + +class MockProject: + pass + + +@pytest.mark.core +def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd): + """Test opening a project in a folder. + """ + theProject = NWProject(mockGUI) + mockRnd.reset() + buildTestProject(theProject, fncPath) + theProject.closeProject() + + # Create instance + storage = NWStorage(theProject) + + # Check defaults + assert storage.storagePath is None + assert storage.runtimePath is None + assert storage.contentPath is None + assert storage._openMode == NWStorage.MODE_INACTIVE + + # Check closed project return values + assert storage.isOpen() is False + assert storage.getXmlReader() is None + assert storage.getXmlWriter() is None + assert bool(storage.getDocument(C.hSceneDoc)) is False + assert storage.getMetaFile("file") is None + assert storage.getCacheFile("file") is None + + # Open project as a new project should fail + assert storage.openProjectInPlace(fncPath, newProject=True) is False + + # Opening as a no-new project is fine + assert storage.openProjectInPlace(fncPath, newProject=False) is True + + # Opening the project file is also fine + assert storage.openProjectInPlace(fncPath / nwFiles.PROJ_FILE, newProject=False) is True + + # Check settings + assert storage.storagePath == fncPath + assert storage.runtimePath == fncPath + assert storage.contentPath == fncPath / "content" + assert storage._openMode == NWStorage.MODE_INPLACE + + # Open the project itself + theProject.openProject(fncPath) + storage = theProject.storage + + # Get XML components + assert isinstance(storage.getXmlReader(), ProjectXMLReader) + assert isinstance(storage.getXmlWriter(), ProjectXMLWriter) + + # Get document + assert storage.getDocument(C.hSceneDoc).readDocument() == "### New Scene\n\n" + + # Get paths + assert storage.getMetaFile("stuff") == fncPath / "meta" / "stuff" + assert storage.getCacheFile("stuff") == fncPath / "cache" / "stuff" + + # Clean up + assert theProject.closeProject() is True + + # Check closed project return values (again) + assert storage.isOpen() is False + assert storage.getXmlReader() is None + assert storage.getXmlWriter() is None + assert bool(storage.getDocument(C.hSceneDoc)) is False + assert storage.getMetaFile("file") is None + assert storage.getCacheFile("file") is None + +# END Test testCoreStorage_ProjectInPlace + + +@pytest.mark.core +def testCoreStorage_LockFile(monkeypatch, fncPath): + """Test the project lock file. + """ + monkeypatch.setattr("novelwriter.core.storage.time", lambda: 1000.0) + + storage = NWStorage(MockProject()) + assert storage.isOpen() is False + + # Project not open, so cannot read/write lock file + assert storage.readLockFile() == ["ERROR"] + assert storage.writeLockFile() is False + assert storage.clearLockFile() is False + + # Set a path to work with + lockFilePath = fncPath / nwFiles.PROJ_LOCK + storage._lockFilePath = lockFilePath + + # Path is set, but there is no lockfile + assert storage.readLockFile() == [] + + # Write lockfile fails + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.write_text", causeOSError) + assert storage.writeLockFile() is False + assert not lockFilePath.exists() + + # Successful write + assert storage.writeLockFile() is True + assert lockFilePath.exists() + assert lockFilePath.read_text().split(";")[3] == "1000" + + # Read lockfile fails + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.read_text", causeOSError) + assert storage.readLockFile() == ["ERROR"] + assert lockFilePath.exists() + + # Successful read + assert storage.readLockFile() == [ + storage.mainConf.hostName, + storage.mainConf.osType, + storage.mainConf.kernelVer, + "1000", + ] + + # Write an invalid lockfile + writeFile(lockFilePath, "a;b;c") + assert storage.readLockFile() == ["ERROR"] + + # Fail to remove lockfile + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.unlink", causeOSError) + assert storage.clearLockFile() is False + assert lockFilePath.exists() + + # Successful remove + assert storage.clearLockFile() is True + assert not lockFilePath.exists() + +# END Test testCoreStorage_LockFile + + +@pytest.mark.core +def testCoreStorage_PrepareStorage(monkeypatch, fncPath): + """Test the project path preparation functions. + """ + storage = NWStorage(MockProject()) + assert storage.isOpen() is False + + # No path set + assert storage._prepareStorage() is False + + # Set path to home + storage._runtimePath = fncPath + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.home", lambda: fncPath) + assert storage._prepareStorage() is False + + # Fail on mkdir + storage._runtimePath = fncPath + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.mkdir", causeOSError) + assert storage._prepareStorage() is False + + # Set up the folder + storage._runtimePath = fncPath + assert storage._prepareStorage(checkLegacy=False) is True + assert (fncPath / "content").exists() + assert (fncPath / "cache").exists() + assert (fncPath / "meta").exists() + + # Add a legacy folder + storage._runtimePath = fncPath + dataDir = fncPath / "data_0" + dataDir.mkdir() + assert storage._prepareStorage(checkLegacy=True) is True + assert not dataDir.exists() + + # We cannot add a new project here + storage._runtimePath = fncPath + assert storage._prepareStorage(checkLegacy=False, newProject=True) is False + + # Legacy Data Folder + # ================== + storage._runtimePath = fncPath + + data = [] + files = [] + for c in "0123456789abcdefX": + dataDir = fncPath / f"data_{c}" + dataDir.mkdir() + data.append(dataDir) + + nwdFile = dataDir / f"00000000000{c}_main.nwd" + bakFile = dataDir / f"00000000000{c}_main.bak" + nwdFile.write_text("#") + bakFile.write_text("#") + files.append(nwdFile) + files.append(bakFile) + + for item in files: + assert item.exists() + + # Pollute folder 7 and 8 + (data[7] / "stuff.txt").write_text("foo") + (data[8] / "bar").mkdir() + + # Process folders + for i in range(9): + storage._legacyDataFolder(fncPath, data[i]) + + # Files form 0 to 8 should now be in content + for c in "012345678": + assert (fncPath / "content" / f"{c}00000000000{c}.nwd").exists() + + # Folders 0 to 6 should be deleted + for i in range(7): + assert not data[i].exists() + + # While 7 and 8 remain + assert data[7].exists() + assert data[8].exists() + + # So does folder X, which is invalid + storage._legacyDataFolder(fncPath, data[16]) + assert data[16].exists() + + # Fail cleanup of folder 9 + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.rename", causeOSError) + mp.setattr("pathlib.Path.unlink", causeOSError) + storage._legacyDataFolder(fncPath, data[9]) + assert data[9].exists() + assert not (fncPath / "content" / "9000000000009.nwd").exists() + + # Run the remaining through the prepare storage call + assert storage._prepareStorage(checkLegacy=True) is True + for c in "0123456789abcdef": + assert (fncPath / "content" / f"{c}00000000000{c}.nwd").exists() + + # Deprecated Files + # ================ + + remove = [ + fncPath / "meta" / "mainOptions.json", + fncPath / "meta" / "exportOptions.json", + fncPath / "meta" / "outlineOptions.json", + fncPath / "meta" / "timelineOptions.json", + fncPath / "meta" / "docMergeOptions.json", + fncPath / "meta" / "sessionLogOptions.json", + fncPath / "ToC.json", + ] + for depFile in remove: + depFile.write_text("foo") + assert depFile.exists() + + with monkeypatch.context() as mp: + mp.setattr("pathlib.Path.unlink", causeOSError) + storage._deleteDeprecatedFiles(fncPath) + for depFile in remove: + assert depFile.exists() + + storage._deleteDeprecatedFiles(fncPath) + for depFile in remove: + assert not depFile.exists() + +# END Test testCoreStorage_PrepareStorage + + +@pytest.mark.core +def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tmpPath, mockRnd): + """Test making a zip archive of a project. + """ + zipFile = tmpPath / "project.zip" + + theProject = NWProject(mockGUI) + storage = theProject.storage + assert storage.zipIt(zipFile) is False + + # Make a project + mockRnd.reset() + buildTestProject(theProject, fncPath) + + # Fail to create archive + with monkeypatch.context() as mp: + mp.setattr("novelwriter.core.storage.ZipFile.write", causeOSError) + assert storage.zipIt(zipFile) is False + + # Create archive + assert storage.zipIt(zipFile) is True + + # Check content + with ZipFile(zipFile, mode="r") as archive: + names = archive.namelist() + assert names[0] == nwFiles.PROJ_FILE + assert names[1] == f"meta/{nwFiles.OPTS_FILE}" + assert names[2] == f"meta/{nwFiles.INDEX_FILE}" + assert names[3] == f"content/{C.hTitlePage}.nwd" + assert names[4] == f"content/{C.hChapterDoc}.nwd" + assert names[5] == f"content/{C.hSceneDoc}.nwd" + + theProject.closeProject() + +# END Test testCoreStorage_ZipIt