Refactor Storage class (#1635)

This commit is contained in:
Veronica Berglyd Olsen
2023-12-01 18:22:26 +01:00
committed by GitHub
10 changed files with 714 additions and 585 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ class ProjectBuilder:
return False
project = NWProject()
if not project.storage.openProjectInPlace(projPath, newProject=True):
if not project.storage.createNewProject(projPath):
return False
lblNewProject = self.tr("New Project")
+37 -36
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import json
import logging
from enum import Enum
from time import time
from typing import TYPE_CHECKING, Iterator
from pathlib import Path
@@ -40,7 +41,7 @@ from novelwriter.constants import trConst, nwLabels
from novelwriter.core.tree import NWTree
from novelwriter.core.index import NWIndex
from novelwriter.core.options import OptionState
from novelwriter.core.storage import NWStorage
from novelwriter.core.storage import NWStorage, NWStorageOpen
from novelwriter.core.sessions import NWSessionLog
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
from novelwriter.core.projectdata import NWProjectData
@@ -55,6 +56,16 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__)
class NWProjectState(Enum):
UNKNOWN = 0
LOCKED = 1
RECOVERY = 2
READY = 3
# END Enum NWProjectState
class NWProject:
def __init__(self) -> None:
@@ -69,9 +80,9 @@ class NWProject:
# Project Status
self._langData = {} # Localisation data
self._lockedBy = None # Data on which computer has the project open
self._changed = False # The project has unsaved changes
self._valid = False # The project was successfully loaded
self._state = NWProjectState.UNKNOWN
# Internal Mapping
self.tr = partial(QCoreApplication.translate, "NWProject")
@@ -125,12 +136,15 @@ class NWProject:
"""Return True if a project is loaded."""
return self._valid
@property
def state(self) -> NWProjectState:
"""Return the current project state."""
return self._state
@property
def lockStatus(self) -> list | None:
"""Return the project lock information."""
if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4:
return self._lockedBy
return None
return self._storage.lockStatus
@property
def currentEditTime(self) -> int:
@@ -219,29 +233,21 @@ class NWProject:
build the tree of project items.
"""
logger.info("Opening project: %s", projPath)
if not self._storage.openProjectInPlace(projPath):
SHARED.error(self.tr("Could not open project with path: {0}").format(projPath))
status = self._storage.initProjectStorage(projPath, clearLock)
if status != NWStorageOpen.READY:
if status == NWStorageOpen.UNKOWN:
SHARED.error(self.tr("Not a known project file format."))
elif status == NWStorageOpen.NOT_FOUND:
SHARED.error(self.tr("Project file not found."))
elif status == NWStorageOpen.LOCKED:
self._state = NWProjectState.LOCKED
elif status == NWStorageOpen.FAILED:
SHARED.error(self.tr("Failed to open project."), exc=self._storage.exc)
return False
# Project Lock
# ============
if clearLock:
self._storage.clearLockFile()
lockStatus = self._storage.readLockFile()
if len(lockStatus) > 0:
if lockStatus[0] == "ERROR":
logger.warning("Failed to check lock file")
else:
logger.error("Project is locked, so not opening")
self._lockedBy = lockStatus
return False
else:
logger.debug("Project is not locked")
# Open The Project XML File
# =========================
# Read Project XML
# ================
xmlReader = self._storage.getXmlReader()
if not isinstance(xmlReader, ProjectXMLReader):
@@ -250,9 +256,7 @@ class NWProject:
self._data = NWProjectData(self)
projContent = []
xmlParsed = xmlReader.read(self._data, projContent)
appVersion = xmlReader.appVersion or self.tr("Unknown")
if not xmlParsed:
if xmlReader.state == XMLReadState.NOT_NWX_FILE:
SHARED.error(self.tr(
@@ -302,8 +306,7 @@ class NWProject:
self._loadProjectLocalisation()
# Update recent projects
storePath = self._storage.storagePath
if storePath:
if storePath := self._storage.storagePath:
CONFIG.recentProjects.update(
storePath, self._data.name, sum(self._data.initCounts), time()
)
@@ -323,9 +326,9 @@ class NWProject:
self.updateWordCounts()
self._session.startSession()
self._storage.writeLockFile()
self.setProjectChanged(False)
self._valid = True
self._state = NWProjectState.READY
SHARED.newStatusMessage(self.tr("Opened Project: {0}").format(self._data.name))
@@ -370,13 +373,11 @@ class NWProject:
self._storage.runPostSaveTasks(autoSave=autoSave)
# Update recent projects
storePath = self._storage.storagePath
if storePath:
if storagePath := self._storage.storagePath:
CONFIG.recentProjects.update(
storePath, self._data.name, sum(self._data.currCounts), saveTime
storagePath, self._data.name, sum(self._data.currCounts), saveTime
)
self._storage.writeLockFile()
SHARED.newStatusMessage(self.tr("Saved Project: {0}").format(self._data.name))
self.setProjectChanged(False)
@@ -420,8 +421,8 @@ class NWProject:
timeStamp = formatTimeStamp(time(), fileSafe=True)
archName = baseDir / f"{cleanName} {timeStamp}.zip"
if self._storage.zipIt(archName, compression=2):
size = formatInt(getFileSize(archName))
if doNotify:
size = formatInt(getFileSize(archName))
SHARED.info(
self.tr("Created a backup of your project of size {0}B.").format(size),
info=self.tr("Path: {0}").format(str(backupPath))
+5 -7
View File
@@ -38,7 +38,6 @@ from novelwriter.common import (
checkBool, checkInt, checkString, checkStringNone, formatTimeStamp,
hexToInt, simplified, xmlIndent, yesNo
)
from novelwriter.constants import nwFiles
if TYPE_CHECKING: # pragma: no cover
from novelwriter.core.status import NWStatus
@@ -558,9 +557,8 @@ class ProjectXMLWriter:
xName.text = item["name"]
# Write the XML tree to file
saveFile = self._path / nwFiles.PROJ_FILE
tempFile = saveFile.with_suffix(".tmp")
backFile = saveFile.with_suffix(".bak")
tempFile = self._path.with_suffix(".tmp")
backFile = self._path.with_suffix(".bak")
try:
xml = ET.ElementTree(xRoot)
xmlIndent(xml)
@@ -572,9 +570,9 @@ class ProjectXMLWriter:
# If we're here, the file was successfully saved,
# so let's sort out the temps and backups
try:
if saveFile.exists():
saveFile.replace(backFile)
tempFile.replace(saveFile)
if self._path.exists():
self._path.replace(backFile)
tempFile.replace(self._path)
except Exception as exc:
self._error = exc
return False
+166 -137
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import json
import logging
from enum import Enum
from time import time
from typing import TYPE_CHECKING
from pathlib import Path
@@ -45,6 +46,17 @@ if TYPE_CHECKING: # pragma: no cover
logger = logging.getLogger(__name__)
class NWStorageOpen(Enum):
UNKOWN = 0
NOT_FOUND = 1
LOCKED = 2
FAILED = 3
READY = 4
# END Enum NWStorageOpen
class NWStorage:
"""Core: Project Storage Class
@@ -60,7 +72,10 @@ class NWStorage:
self._storagePath = None
self._runtimePath = None
self._lockFilePath = None
self._lockedBy = None
self._openMode = self.MODE_INACTIVE
self._ready = False
self._exception = None
return
def clear(self) -> None:
@@ -69,6 +84,7 @@ class NWStorage:
self._runtimePath = None
self._lockFilePath = None
self._openMode = self.MODE_INACTIVE
self._ready = False
return
##
@@ -77,12 +93,12 @@ class NWStorage:
@property
def storagePath(self) -> Path | None:
"""Get the path where the project is saved."""
"""Return the path where the project is stored."""
return self._storagePath
@property
def runtimePath(self) -> Path | None:
"""Get the path where the project is saved at runtime."""
"""Return the path where the project is stored at runtime."""
return self._runtimePath
@property
@@ -97,26 +113,31 @@ class NWStorage:
logger.error("Content path cannot be resolved")
return None
@property
def lockStatus(self) -> list | None:
"""Return the project lock information."""
if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4:
return self._lockedBy
return None
@property
def exc(self) -> Exception | None:
"""Return the latest exception of the storage instance."""
return self._exception
##
# Core Methods
##
def isOpen(self) -> bool:
"""Check if the storage location is open."""
return self._runtimePath is not None
return self._ready and self._runtimePath is not None
def openProjectInPlace(self, path: str | Path, newProject: bool = False) -> bool:
"""Open a novelWriter project in-place. That is, it is opened
directly from a project folder.
"""
def createNewProject(self, path: str | Path) -> bool:
"""Create a new project at the given location."""
inPath = Path(path).resolve()
if inPath.is_file():
# The path should not point to an existing file,
# but it can point to a folder containing files
inPath = inPath.parent
if not (inPath.is_dir() or newProject):
# If the project is not new, the folder must already exist.
if inPath.is_dir() and len(list(inPath.iterdir())) > 0:
logger.error("Folder is not empty: %s", inPath)
return False
self._storagePath = inPath
@@ -124,17 +145,91 @@ class NWStorage:
self._lockFilePath = inPath / nwFiles.PROJ_LOCK
self._openMode = self.MODE_INPLACE
if not self._prepareStorage(checkLegacy=True, newProject=newProject):
basePath = self._runtimePath
metaPath = basePath / "meta"
contPath = basePath / "content"
try:
basePath.mkdir(exist_ok=True)
metaPath.mkdir(exist_ok=True)
contPath.mkdir(exist_ok=True)
except Exception as exc:
self._exception = exc
logger.error("Failed to create project folders", exc_info=exc)
self.clear()
return False
self._ready = True
return True
def openProjectArchive(self, path: str | Path) -> bool: # pragma: no cover
"""Open the project from a single file.
Placeholder for later implementation. See #977.
"""
return False
def initProjectStorage(self, path: str | Path, clearLock: bool = False) -> NWStorageOpen:
"""Initialise a novelWriter project location."""
inPath = Path(path).resolve()
# Initialise Storage Instance
# ===========================
# Check what we're opening. Only two options are allowed:
# 1. A folder with an nwProject.nwx file in it (not home)
# 2. A full path to an nwProject.nwx file
if inPath.is_dir() and inPath != Path.home().resolve():
nwxFile = inPath / nwFiles.PROJ_FILE
elif inPath.is_file() and inPath.name == nwFiles.PROJ_FILE:
nwxFile = inPath
else:
logger.error("Not a novelWriter project")
return NWStorageOpen.UNKOWN
if not nwxFile.exists():
# The .nwx file must exist to continue
logger.error("Not found: %s", nwxFile)
return NWStorageOpen.NOT_FOUND
nwxPath = nwxFile.parent
self._storagePath = nwxPath
self._runtimePath = nwxPath
self._lockFilePath = nwxPath / nwFiles.PROJ_LOCK
self._openMode = self.MODE_INPLACE
# Check Project Lock
# ==================
if clearLock:
self._clearLockFile()
self._readLockFile()
if self._lockedBy:
logger.error("Project is locked, so not opening")
return NWStorageOpen.LOCKED
else:
logger.debug("Project is not locked")
# Prepare Folder
# ==============
basePath = self._runtimePath
metaPath = basePath / "meta"
contPath = basePath / "content"
try:
metaPath.mkdir(exist_ok=True)
contPath.mkdir(exist_ok=True)
except Exception as exc:
logger.error("Failed to create project folders", exc_info=exc)
self.clear()
return NWStorageOpen.FAILED
# Check for legacy data folders
legacy = _LegacyStorage(self._project)
legacy.deprecatedFiles(basePath)
for child in basePath.iterdir():
if child.is_dir() and child.name.startswith("data_"):
legacy.legacyDataFolder(basePath, child)
self._writeLockFile()
self._ready = True
return NWStorageOpen.READY
def runPostSaveTasks(self, autoSave: bool = False) -> bool: # pragma: no cover
"""Run tasks after the project has been saved.
@@ -147,7 +242,7 @@ class NWStorage:
def closeSession(self) -> None:
"""Run tasks related to closing the session."""
self.clearLockFile()
self._clearLockFile()
self.clear()
return
@@ -157,26 +252,25 @@ class NWStorage:
def getXmlReader(self) -> ProjectXMLReader | None:
"""Return a properly configured ProjectXMLReader instance."""
if isinstance(self._runtimePath, Path):
projFile = self._runtimePath / nwFiles.PROJ_FILE
return ProjectXMLReader(projFile)
if isinstance(self._runtimePath, Path) and self._ready:
return ProjectXMLReader(self._runtimePath / nwFiles.PROJ_FILE)
return None
def getXmlWriter(self) -> ProjectXMLWriter | None:
"""Return a properly configured ProjectXMLWriter instance."""
if isinstance(self._runtimePath, Path):
return ProjectXMLWriter(self._runtimePath)
if isinstance(self._runtimePath, Path) and self._ready:
return ProjectXMLWriter(self._runtimePath / nwFiles.PROJ_FILE)
return None
def getDocument(self, tHandle: str | None) -> NWDocument:
"""Return a document wrapper object."""
if isinstance(self._runtimePath, Path):
if isinstance(self._runtimePath, Path) and self._ready:
return NWDocument(self._project, tHandle)
return NWDocument(self._project, None)
def getMetaFile(self, fileName: str) -> Path | None:
"""Return the path to a file in the project meta folder."""
if isinstance(self._runtimePath, Path):
if isinstance(self._runtimePath, Path) and self._ready:
return self._runtimePath / "meta" / fileName
return None
@@ -190,59 +284,6 @@ class NWStorage:
if item.suffix == ".nwd" and isHandle(item.stem)
] if contentPath else []
def readLockFile(self) -> list[str]:
"""Read the project lock file."""
if self._lockFilePath is None:
return ["ERROR"]
if not self._lockFilePath.exists():
return []
try:
lines = self._lockFilePath.read_text(encoding="utf-8").strip().split(";")
except Exception:
logger.error("Failed to read project lockfile")
logException()
return ["ERROR"]
if len(lines) != 4:
return ["ERROR"]
return lines
def writeLockFile(self) -> bool:
"""Write the project lock file."""
if self._lockFilePath is None:
return False
data = [
CONFIG.hostName, CONFIG.osType,
CONFIG.kernelVer, str(int(time()))
]
try:
self._lockFilePath.write_text(";".join(data), encoding="utf-8")
except Exception:
logger.error("Failed to write project lockfile")
logException()
return False
return True
def clearLockFile(self) -> bool:
"""Remove the lock file, if it exists."""
if self._lockFilePath is None:
return False
if self._lockFilePath.exists():
try:
self._lockFilePath.unlink()
except Exception:
logger.error("Failed to remove project lockfile")
logException()
return False
return True
def zipIt(self, target: str | Path, compression: int | None = None) -> bool:
"""Zip the content of the project at its runtime location into a
zip file. This process will only grab files that are supposed to
@@ -279,7 +320,7 @@ class NWStorage:
zipObj.write(srcPath, zipPath)
logger.debug("Added: %s", zipPath)
except Exception:
logger.error("Failed to create acrhive")
logger.error("Failed to create archive")
logException()
return False
@@ -289,59 +330,48 @@ class NWStorage:
# Internal Functions
##
def _prepareStorage(self, checkLegacy: bool = True, newProject: bool = False) -> bool:
"""Prepare the storage area for the project."""
path = self._runtimePath
if not isinstance(path, Path):
logger.error("No path set")
self.clear()
def _readLockFile(self) -> None:
"""Read the project lock file."""
self._lockedBy = None
path = self._lockFilePath
if isinstance(path, Path) and path.exists():
try:
self._lockedBy = path.read_text(encoding="utf-8").strip().split(";")
except Exception:
logger.error("Failed to read project lockfile")
logException()
self._lockedBy = ["ERROR", "ERROR", "ERROR", "ERROR"]
return
return
def _writeLockFile(self) -> bool:
"""Write the project lock file."""
if self._lockFilePath is None:
return False
if path == Path.home().absolute():
logger.error("Cannot use the user's home path as the root of a project")
self.clear()
return False
if newProject:
# If it's a new project, we check that there is no existing
# project in the selected path.
if path.exists() and len(list(path.iterdir())) > 0:
logger.error("The new project folder is not empty")
self.clear()
return False
# The folder is not required to exist, as it could be a new
# project, so we make sure it does. Then we add subfolders.
try:
path.mkdir(exist_ok=True)
(path / "content").mkdir(exist_ok=True)
(path / "meta").mkdir(exist_ok=True)
except Exception as exc:
logger.error("Failed to create required project folders", exc_info=exc)
self.clear()
self._lockFilePath.write_text(
f"{CONFIG.hostName};{CONFIG.osType};{CONFIG.kernelVer};{int(time())}",
encoding="utf-8"
)
except Exception:
logger.error("Failed to write project lockfile")
logException()
return False
if not checkLegacy:
# The legacy content check is only needed for project folder
# storage, so if it is not expected to be that, there's no
# need for the remaining checks.
return True
legacy = _LegacyStorage(self._project)
# Check for legacy data folders
for child in path.iterdir():
if child.is_dir() and child.name.startswith("data_"):
legacy.legacyDataFolder(path, child)
# Check for no longer used files, and delete them
legacy.deprecatedFiles(path)
return True
##
# Legacy Project Data Handlers
##
def _clearLockFile(self) -> bool:
"""Remove the lock file, if it exists."""
if self._lockFilePath is None:
return False
if self._lockFilePath.exists():
try:
self._lockFilePath.unlink()
except Exception:
logger.error("Failed to remove project lockfile")
logException()
return False
self._lockedBy = None
return True
# END Class NWStorage
@@ -350,7 +380,7 @@ class _LegacyStorage:
"""Core: Legacy Storage Converter Utils
A class with various functions to convert old file formats and
file/folder layout to the current project format.
file/folder layouts to the current project format.
"""
def __init__(self, project: NWProject) -> None:
@@ -359,7 +389,8 @@ class _LegacyStorage:
def legacyDataFolder(self, path: Path, child: Path) -> None:
"""Handle the content of a legacy data folder from a version 1.0
project.
project. This format had 16 data folders where there now is only
one content folder.
"""
logger.info("Processing legacy data folder: %s", path)
@@ -411,6 +442,7 @@ class _LegacyStorage:
path / "meta" / nwFiles.OPTS_FILE
)
# Delete removed files
remove = [
path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1
path / "meta" / "mainOptions.json", # Replaced in 0.5
@@ -457,7 +489,6 @@ class _LegacyStorage:
# Save dictionary and clean up old file
userDict.save()
assert wordJson.exists()
wordList.unlink()
except Exception:
@@ -467,9 +498,7 @@ class _LegacyStorage:
return
def _convertOldLogFile(self, sessLog: Path, sessJson: Path) -> None:
"""Convert the old text log file format to the new JSON Lines
format.
"""
"""Convert the old text log file format to JSON Lines."""
if sessJson.exists() or not sessLog.exists():
# If the new file already exists, we won't overwrite it
return
@@ -508,7 +537,7 @@ class _LegacyStorage:
return
def _convertOldOptionsFile(self, optsOld: Path, optsNew: Path) -> None:
"""Convert the old options state file format to the format."""
"""Convert the old options state file format to new format."""
if optsNew.exists() or not optsOld.exists():
# If the new file already exists, we won't overwrite it
return
+1 -2
View File
@@ -462,8 +462,7 @@ class GuiMain(QMainWindow):
if projFile is None:
return False
# Make sure any open project is cleared out first before we load
# another one
# Make sure any open project is cleared out first
if not self.closeProject():
return False
+44 -43
View File
@@ -34,8 +34,8 @@ from novelwriter.gui.noveltree import NovelTreeColumn
@pytest.mark.core
def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
"""Test loading and saving from the OptionState class."""
theProject = NWProject()
theOpts = OptionState(theProject)
project = NWProject()
options = OptionState(project)
metaDir = fncPath / "meta"
metaDir.mkdir()
@@ -58,25 +58,26 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
}), encoding="utf-8")
# Load and save with no path set
theProject.storage._runtimePath = None
assert theOpts.loadSettings() is False
assert theOpts.saveSettings() is False
project.storage._runtimePath = None
assert options.loadSettings() is False
assert options.saveSettings() is False
# Set path
theProject.storage._runtimePath = fncPath
assert theProject.storage.getMetaFile(nwFiles.OPTS_FILE) == optFile
project.storage._runtimePath = fncPath
project.storage._ready = True
assert project.storage.getMetaFile(nwFiles.OPTS_FILE) == optFile
# Cause open() to fail
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert theOpts.loadSettings() is False
assert theOpts.saveSettings() is False
assert options.loadSettings() is False
assert options.saveSettings() is False
# Load proper
assert theOpts.loadSettings()
assert options.loadSettings()
# Check that unwanted items have been removed
assert theOpts._state == {
assert options._state == {
"GuiProjectSettings": {
"winWidth": 570,
"winHeight": 375,
@@ -87,11 +88,11 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
}
# Save proper
assert theOpts.saveSettings()
assert options.saveSettings()
# Load again to check we get the values back
assert theOpts.loadSettings()
assert theOpts._state == {
assert options.loadSettings()
assert options._state == {
"GuiProjectSettings": {
"winWidth": 570,
"winHeight": 375,
@@ -107,48 +108,48 @@ def testCoreOptions_LoadSave(monkeypatch, mockGUI, fncPath):
@pytest.mark.core
def testCoreOptions_SetGet(mockGUI):
"""Test setting and getting values from the OptionState class."""
theProject = NWProject()
theOpts = OptionState(theProject)
project = NWProject()
options = OptionState(project)
nwColHidden = NovelTreeColumn.HIDDEN
# Set invalid values
assert theOpts.setValue("MockGroup", "mockItem", None) is False
assert theOpts.setValue("GuiProjectSettings", "mockItem", None) is False
assert options.setValue("MockGroup", "mockItem", None) is False
assert options.setValue("GuiProjectSettings", "mockItem", None) is False
# Set valid value
assert theOpts.setValue("GuiProjectSettings", "winWidth", 100) is True
assert options.setValue("GuiProjectSettings", "winWidth", 100) is True
# Set some values of different types
assert theOpts.setValue("GuiProjectDetails", "winWidth", 100) is True
assert theOpts.setValue("GuiProjectDetails", "winHeight", 12.34) is True
assert theOpts.setValue("GuiProjectDetails", "clearDouble", True) is True
assert theOpts.setValue("GuiNovelView", "lastCol", nwColHidden) is True
assert options.setValue("GuiProjectDetails", "winWidth", 100) is True
assert options.setValue("GuiProjectDetails", "winHeight", 12.34) is True
assert options.setValue("GuiProjectDetails", "clearDouble", True) is True
assert options.setValue("GuiNovelView", "lastCol", nwColHidden) is True
# Generic get, doesn't check type
assert theOpts.getValue("GuiProjectDetails", "winWidth", None) == 100
assert theOpts.getValue("GuiProjectDetails", "winHeight", None) == 12.34
assert theOpts.getValue("GuiProjectDetails", "clearDouble", None) is True
assert theOpts.getValue("GuiProjectDetails", "mockItem", None) is None
assert options.getValue("GuiProjectDetails", "winWidth", None) == 100
assert options.getValue("GuiProjectDetails", "winHeight", None) == 12.34
assert options.getValue("GuiProjectDetails", "clearDouble", None) is True
assert options.getValue("GuiProjectDetails", "mockItem", None) is None
# Get type-specific
assert theOpts.getString("GuiProjectDetails", "winWidth", None) is None
assert theOpts.getString("GuiProjectDetails", "mockItem", None) is None
assert theOpts.getInt("GuiProjectDetails", "winWidth", None) == 100
assert theOpts.getInt("GuiProjectDetails", "textFont", None) is None
assert theOpts.getInt("GuiProjectDetails", "mockItem", None) is None
assert theOpts.getFloat("GuiProjectDetails", "winWidth", None) == 100.0
assert theOpts.getFloat("GuiProjectDetails", "mockItem", None) is None
assert theOpts.getBool("GuiProjectDetails", "clearDouble", None) is True
assert theOpts.getBool("GuiProjectDetails", "mockItem", None) is None
assert theOpts.getEnum("GuiNovelView", "lastCol", NovelTreeColumn, nwColHidden) == nwColHidden
assert options.getString("GuiProjectDetails", "winWidth", None) is None # type: ignore
assert options.getString("GuiProjectDetails", "mockItem", None) is None # type: ignore
assert options.getInt("GuiProjectDetails", "winWidth", None) == 100 # type: ignore
assert options.getInt("GuiProjectDetails", "textFont", None) is None # type: ignore
assert options.getInt("GuiProjectDetails", "mockItem", None) is None # type: ignore
assert options.getFloat("GuiProjectDetails", "winWidth", None) == 100.0 # type: ignore
assert options.getFloat("GuiProjectDetails", "mockItem", None) is None # type: ignore
assert options.getBool("GuiProjectDetails", "clearDouble", None) is True # type: ignore
assert options.getBool("GuiProjectDetails", "mockItem", None) is None # type: ignore
assert options.getEnum("GuiNovelView", "lastCol", NovelTreeColumn, nwColHidden) == nwColHidden
# Get from non-existent groups
assert theOpts.getValue("SomeGroup", "mockItem", None) is None
assert theOpts.getString("SomeGroup", "mockItem", None) is None
assert theOpts.getInt("SomeGroup", "mockItem", None) is None
assert theOpts.getFloat("SomeGroup", "mockItem", None) is None
assert theOpts.getBool("SomeGroup", "mockItem", None) is None
assert theOpts.getEnum("SomeGroup", "mockItem", NovelTreeColumn, None) is None
assert options.getValue("SomeGroup", "mockItem", None) is None
assert options.getString("SomeGroup", "mockItem", None) is None # type: ignore
assert options.getInt("SomeGroup", "mockItem", None) is None # type: ignore
assert options.getFloat("SomeGroup", "mockItem", None) is None # type: ignore
assert options.getBool("SomeGroup", "mockItem", None) is None # type: ignore
assert options.getEnum("SomeGroup", "mockItem", NovelTreeColumn, None) is None # type: ignore
# END Test testCoreOptions_SetGet
+258 -245
View File
@@ -33,9 +33,10 @@ from PyQt5.QtWidgets import QMessageBox
from novelwriter import CONFIG, SHARED
from novelwriter.enum import nwItemClass
from novelwriter.constants import nwFiles
from novelwriter.core.item import NWItem
from novelwriter.core.tree import NWTree
from novelwriter.core.index import NWIndex
from novelwriter.core.project import NWProject
from novelwriter.core.project import NWProject, NWProjectState
from novelwriter.core.options import OptionState
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter, XMLReadState
@@ -47,45 +48,45 @@ def testCoreProject_NewRoot(fncPath, tstPaths, mockGUI, mockRnd):
testFile = tstPaths.outDir / "coreProject_NewRoot_nwProject.nwx"
compFile = tstPaths.refDir / "coreProject_NewRoot_nwProject.nwx"
theProject = NWProject()
project = NWProject()
mockRnd.reset()
buildTestProject(theProject, fncPath)
buildTestProject(project, fncPath)
assert theProject.newRoot(nwItemClass.NOVEL) == "0000000000010"
assert theProject.newRoot(nwItemClass.PLOT) == "0000000000011"
assert theProject.newRoot(nwItemClass.CHARACTER) == "0000000000012"
assert theProject.newRoot(nwItemClass.WORLD) == "0000000000013"
assert theProject.newRoot(nwItemClass.TIMELINE) == "0000000000014"
assert theProject.newRoot(nwItemClass.OBJECT) == "0000000000015"
assert theProject.newRoot(nwItemClass.CUSTOM) == "0000000000016"
assert theProject.newRoot(nwItemClass.CUSTOM) == "0000000000017"
assert project.newRoot(nwItemClass.NOVEL) == "0000000000010"
assert project.newRoot(nwItemClass.PLOT) == "0000000000011"
assert project.newRoot(nwItemClass.CHARACTER) == "0000000000012"
assert project.newRoot(nwItemClass.WORLD) == "0000000000013"
assert project.newRoot(nwItemClass.TIMELINE) == "0000000000014"
assert project.newRoot(nwItemClass.OBJECT) == "0000000000015"
assert project.newRoot(nwItemClass.CUSTOM) == "0000000000016"
assert project.newRoot(nwItemClass.CUSTOM) == "0000000000017"
assert theProject.projChanged is True
assert theProject.saveProject() is True
theProject.closeProject()
assert project.projChanged is True
assert project.saveProject() is True
project.closeProject()
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE)
assert theProject.projChanged is False
assert project.projChanged is False
# Delete the new items
assert theProject.removeItem("0000000000010") is True
assert theProject.removeItem("0000000000011") is True
assert theProject.removeItem("0000000000012") is True
assert theProject.removeItem("0000000000013") is True
assert theProject.removeItem("0000000000014") is True
assert theProject.removeItem("0000000000015") is True
assert theProject.removeItem("0000000000016") is True
assert theProject.removeItem("0000000000017") is True
assert project.removeItem("0000000000010") is True
assert project.removeItem("0000000000011") is True
assert project.removeItem("0000000000012") is True
assert project.removeItem("0000000000013") is True
assert project.removeItem("0000000000014") is True
assert project.removeItem("0000000000015") is True
assert project.removeItem("0000000000016") is True
assert project.removeItem("0000000000017") is True
assert "0000000000010" not in theProject.tree
assert "0000000000011" not in theProject.tree
assert "0000000000012" not in theProject.tree
assert "0000000000013" not in theProject.tree
assert "0000000000014" not in theProject.tree
assert "0000000000015" not in theProject.tree
assert "0000000000016" not in theProject.tree
assert "0000000000017" not in theProject.tree
assert "0000000000010" not in project.tree
assert "0000000000011" not in project.tree
assert "0000000000012" not in project.tree
assert "0000000000013" not in project.tree
assert "0000000000014" not in project.tree
assert "0000000000015" not in project.tree
assert "0000000000016" not in project.tree
assert "0000000000017" not in project.tree
# END Test testCoreProject_NewRoot
@@ -97,68 +98,68 @@ def testCoreProject_NewFileFolder(monkeypatch, fncPath, tstPaths, mockGUI, mockR
testFile = tstPaths.outDir / "coreProject_NewFileFolder_nwProject.nwx"
compFile = tstPaths.refDir / "coreProject_NewFileFolder_nwProject.nwx"
theProject = NWProject()
project = NWProject()
mockRnd.reset()
buildTestProject(theProject, fncPath)
buildTestProject(project, fncPath)
# Invalid call
assert theProject.newFolder("New Folder", "1234567890abc") is None
assert theProject.newFile("New File", "1234567890abc") is None
assert project.newFolder("New Folder", "1234567890abc") is None
assert project.newFile("New File", "1234567890abc") is None
# Add files properly
assert theProject.newFolder("Stuff", C.hNovelRoot) == "0000000000010"
assert theProject.newFile("Hello", "0000000000010") == "0000000000011"
assert theProject.newFile("Jane", C.hCharRoot) == "0000000000012"
assert project.newFolder("Stuff", C.hNovelRoot) == "0000000000010"
assert project.newFile("Hello", "0000000000010") == "0000000000011"
assert project.newFile("Jane", C.hCharRoot) == "0000000000012"
assert "0000000000010" in theProject.tree
assert "0000000000011" in theProject.tree
assert "0000000000012" in theProject.tree
assert "0000000000010" in project.tree
assert "0000000000011" in project.tree
assert "0000000000012" in project.tree
# Write to file, failed
assert theProject.writeNewFile("blabla", 1, True) is False # Not a handle
assert theProject.writeNewFile("0000000000010", 1, True) is False # Not a file
assert theProject.writeNewFile(C.hTitlePage, 1, True) is False # Already has content
assert project.writeNewFile("blabla", 1, True) is False # Not a handle
assert project.writeNewFile("0000000000010", 1, True) is False # Not a file
assert project.writeNewFile(C.hTitlePage, 1, True) is False # Already has content
# Write to file, success
assert theProject.writeNewFile("0000000000011", 2, True) is True
assert theProject.storage.getDocument("0000000000011").readDocument() == "## Hello\n\n"
assert project.writeNewFile("0000000000011", 2, True) is True
assert project.storage.getDocument("0000000000011").readDocument() == "## Hello\n\n"
# Write to file with additional text, success
assert theProject.writeNewFile("0000000000012", 1, False, "Hi Jane\n\n") is True
assert theProject.storage.getDocument("0000000000012").readDocument() == (
assert project.writeNewFile("0000000000012", 1, False, "Hi Jane\n\n") is True
assert project.storage.getDocument("0000000000012").readDocument() == (
"# Jane\n\nHi Jane\n\n"
)
# Save, close and check
assert theProject.projChanged is True
assert theProject.saveProject() is True
assert project.projChanged is True
assert project.saveProject() is True
copyfile(projFile, testFile)
assert cmpFiles(testFile, compFile, ignoreStart=XML_IGNORE)
assert theProject.projChanged is False
assert project.projChanged is False
# Delete new file, but block access
with monkeypatch.context() as mp:
mp.setattr("pathlib.Path.unlink", causeOSError)
assert theProject.removeItem("0000000000011") is False
assert "0000000000011" in theProject.tree
assert project.removeItem("0000000000011") is False
assert "0000000000011" in project.tree
# Delete new files and folders
assert (fncPath / "content" / "0000000000012.nwd").exists()
assert (fncPath / "content" / "0000000000011.nwd").exists()
assert theProject.removeItem("0000000000012") is True
assert theProject.removeItem("0000000000011") is True
assert theProject.removeItem("0000000000010") is True
assert project.removeItem("0000000000012") is True
assert project.removeItem("0000000000011") is True
assert project.removeItem("0000000000010") is True
assert not (fncPath / "content" / "0000000000012.nwd").exists()
assert not (fncPath / "content" / "0000000000011.nwd").exists()
assert "0000000000010" not in theProject.tree
assert "0000000000011" not in theProject.tree
assert "0000000000012" not in theProject.tree
assert "0000000000010" not in project.tree
assert "0000000000011" not in project.tree
assert "0000000000012" not in project.tree
theProject.closeProject()
project.closeProject()
# END Test testCoreProject_NewFileFolder
@@ -166,93 +167,103 @@ def testCoreProject_NewFileFolder(monkeypatch, fncPath, tstPaths, mockGUI, mockR
@pytest.mark.core
def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd):
"""Test opening a project."""
theProject = NWProject()
project = NWProject()
mockRnd.reset()
buildTestProject(theProject, fncPath)
# buildTestProject(project, fncPath)
# Initialising the storage class fails
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.openProjectInPlace", lambda *a, **k: False)
assert theProject.openProject(fncPath) is False
# Unknown project file
fooBar = fncPath / "foobar.txt"
fooBar.touch()
caplog.clear()
assert project.openProject(fooBar) is False
assert "Not a known project file format." in caplog.text
# Fail on lock file
theProject.storage._lockFilePath = fncPath / nwFiles.PROJ_LOCK
assert theProject.storage.writeLockFile() is True
assert theProject.openProject(fncPath) is False
assert isinstance(theProject.lockStatus, list)
# No project file
caplog.clear()
assert project.openProject(fncPath) is False
assert "Project file not found." in caplog.text
fooBar.unlink()
# Fail to read lockfile (which still opens the project)
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.readLockFile", lambda *a: ["ERROR"])
caplog.clear()
assert theProject.openProject(fncPath) is True
assert "Failed to check lock file" in caplog.text
theProject.closeProject()
# Fail to open project location
projFile = fncPath / nwFiles.PROJ_FILE
metaFile = fncPath / "meta"
projFile.touch()
metaFile.touch()
caplog.clear()
assert project.openProject(fncPath) is False
assert "Failed to open project." in caplog.text
metaFile.unlink()
projFile.unlink()
# Force open with lockfile
theProject.storage._lockFilePath = fncPath / nwFiles.PROJ_LOCK
assert theProject.storage.writeLockFile() is True
assert theProject.openProject(fncPath, clearLock=True) is True
theProject.closeProject()
assert theProject.lockStatus is None
# Create test project
buildTestProject(project, fncPath)
# Open successfully
assert project.openProject(fncPath) is True
# Open again should fail on lock file
assert project.openProject(fncPath) is False
assert project.state == NWProjectState.LOCKED
# Force re-open
assert project.openProject(fncPath, clearLock=True) is True
assert project.state == NWProjectState.READY
# Fail getting xml reader
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getXmlReader", lambda *a: None)
assert theProject.openProject(fncPath) is False
assert project.openProject(fncPath, clearLock=True) is False
# Not a novelwriter XML file
with monkeypatch.context() as mp:
mp.setattr(ProjectXMLReader, "read", lambda *a: False)
mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.NOT_NWX_FILE))
assert theProject.openProject(fncPath) is False
assert project.openProject(fncPath, clearLock=True) is False
assert "Project file does not appear" in SHARED.lastAlert
# Unknown project file version
with monkeypatch.context() as mp:
mp.setattr(ProjectXMLReader, "read", lambda *a: False)
mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.UNKNOWN_VERSION))
assert theProject.openProject(fncPath) is False
assert project.openProject(fncPath, clearLock=True) is False
assert "Unknown or unsupported novelWriter project file" in SHARED.lastAlert
# Other parse error
with monkeypatch.context() as mp:
mp.setattr(ProjectXMLReader, "read", lambda *a: False)
mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.CANNOT_PARSE))
assert theProject.openProject(fncPath) is False
assert project.openProject(fncPath, clearLock=True) is False
assert "Failed to parse project xml" in SHARED.lastAlert
# Won't convert legacy file
with monkeypatch.context() as mp:
mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.WAS_LEGACY))
mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.No)
assert theProject.openProject(fncPath) is False
assert project.openProject(fncPath, clearLock=True) is False
assert "The file format of your project is about to be" in SHARED.lastAlert
# Won't open project from newer version
with monkeypatch.context() as mp:
mp.setattr(ProjectXMLReader, "hexVersion", property(lambda *a: 0x99999999))
mp.setattr(QMessageBox, "result", lambda *a: QMessageBox.No)
assert theProject.openProject(fncPath) is False
assert project.openProject(fncPath, clearLock=True) is False
assert "This project was saved by a newer version" in SHARED.lastAlert
# Fail checking items should still pass
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.tree.NWTree.updateItemData", lambda *a: False)
assert theProject.openProject(fncPath) is True
theProject.closeProject()
assert project.openProject(fncPath, clearLock=True) is True
# Trigger an index rebuild
with monkeypatch.context() as mp:
mp.setattr(ProjectXMLReader, "state", property(lambda *a: XMLReadState.WAS_LEGACY))
mp.setattr("novelwriter.core.index.NWIndex.loadIndex", lambda *a: True)
theProject.index._indexBroken = True
assert theProject.openProject(fncPath) is True
project.index._indexBroken = True
assert project.openProject(fncPath, clearLock=True) is True
assert "The file format of your project is about to be" in SHARED.lastAlert
assert theProject.index._indexBroken is False
assert project.index._indexBroken is False
theProject.closeProject()
project.closeProject()
# END Test testCoreProject_Open
@@ -260,28 +271,28 @@ def testCoreProject_Open(monkeypatch, caplog, mockGUI, fncPath, mockRnd):
@pytest.mark.core
def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncPath):
"""Test saving a project."""
theProject = NWProject()
project = NWProject()
# Nothing to save
assert theProject.saveProject() is False
assert project.saveProject() is False
mockRnd.reset()
buildTestProject(theProject, fncPath)
buildTestProject(project, fncPath)
# Fail getting xml writer
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.storage.NWStorage.getXmlWriter", lambda *a: None)
assert theProject.saveProject() is False
assert project.saveProject() is False
# Fail writing
with monkeypatch.context() as mp:
mp.setattr(ProjectXMLWriter, "write", lambda *a: False)
assert theProject.saveProject() is False
assert project.saveProject() is False
# Save with and without autosave
assert theProject.saveProject(autoSave=False) is True
assert theProject.saveProject(autoSave=True) is True
theProject.closeProject()
assert project.saveProject(autoSave=False) is True
assert project.saveProject(autoSave=True) is True
project.closeProject()
# END Test testCoreProject_Save
@@ -289,13 +300,13 @@ def testCoreProject_Save(monkeypatch, mockGUI, mockRnd, fncPath):
@pytest.mark.core
def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd):
"""Test helper functions for the project folder."""
theProject = NWProject()
buildTestProject(theProject, fncPath)
project = NWProject()
buildTestProject(project, fncPath)
# Storage Objects
assert isinstance(theProject.index, NWIndex)
assert isinstance(theProject.tree, NWTree)
assert isinstance(theProject.options, OptionState)
assert isinstance(project.index, NWIndex)
assert isinstance(project.tree, NWTree)
assert isinstance(project.options, OptionState)
# Move Novel ROOT to after its files
oldOrder = [
@@ -318,20 +329,22 @@ def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd):
C.hCharRoot,
C.hWorldRoot,
]
assert theProject.tree.handles() == oldOrder
theProject.setTreeOrder(newOrder)
assert theProject.tree.handles() == newOrder
assert project.tree.handles() == oldOrder
project.setTreeOrder(newOrder)
assert project.tree.handles() == newOrder
# Add a non-existing item
theProject.tree._order.append(C.hInvalid)
project.tree._order.append(C.hInvalid)
# Add an item with a non-existent parent
nHandle = theProject.newFile("Test File", C.hChapterDir)
theProject.tree[nHandle].setParent("cba9876543210")
assert theProject.tree[nHandle].itemParent == "cba9876543210"
nHandle = project.newFile("Test File", C.hChapterDir)
nItem = project.tree[nHandle]
assert isinstance(nItem, NWItem)
nItem.setParent("cba9876543210")
assert nItem.itemParent == "cba9876543210"
retOrder = []
for tItem in theProject.iterProjectItems():
for tItem in project.iterProjectItems():
retOrder.append(tItem.itemHandle)
assert retOrder == [
@@ -345,7 +358,7 @@ def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd):
C.hChapterDoc,
C.hSceneDoc,
]
assert theProject.tree[nHandle].itemParent is None
assert nItem.itemParent is None
# END Test testCoreProject_AccessItems
@@ -353,9 +366,9 @@ def testCoreProject_AccessItems(mockGUI, fncPath, mockRnd):
@pytest.mark.core
def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd):
"""Test the status and importance flag handling."""
theProject = NWProject()
project = NWProject()
mockRnd.reset()
buildTestProject(theProject, fncPath)
buildTestProject(project, fncPath)
statusKeys = [C.sNew, C.sNote, C.sDraft, C.sFinished]
importKeys = [C.iNew, C.iMinor, C.iMajor, C.iMain]
@@ -363,15 +376,15 @@ def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd):
# Change Status
# =============
theProject.tree[C.hNovelRoot].setStatus(statusKeys[3])
theProject.tree[C.hPlotRoot].setStatus(statusKeys[2])
theProject.tree[C.hCharRoot].setStatus(statusKeys[1])
theProject.tree[C.hWorldRoot].setStatus(statusKeys[3])
project.tree[C.hNovelRoot].setStatus(statusKeys[3]) # type: ignore
project.tree[C.hPlotRoot].setStatus(statusKeys[2]) # type: ignore
project.tree[C.hCharRoot].setStatus(statusKeys[1]) # type: ignore
project.tree[C.hWorldRoot].setStatus(statusKeys[3]) # type: ignore
assert theProject.tree[C.hNovelRoot].itemStatus == statusKeys[3]
assert theProject.tree[C.hPlotRoot].itemStatus == statusKeys[2]
assert theProject.tree[C.hCharRoot].itemStatus == statusKeys[1]
assert theProject.tree[C.hWorldRoot].itemStatus == statusKeys[3]
assert project.tree[C.hNovelRoot].itemStatus == statusKeys[3] # type: ignore
assert project.tree[C.hPlotRoot].itemStatus == statusKeys[2] # type: ignore
assert project.tree[C.hCharRoot].itemStatus == statusKeys[1] # type: ignore
assert project.tree[C.hWorldRoot].itemStatus == statusKeys[3] # type: ignore
newList = [
{"key": statusKeys[0], "name": "New", "cols": (1, 1, 1)},
@@ -380,36 +393,36 @@ def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd):
{"key": statusKeys[3], "name": "Edited", "cols": (4, 4, 4)}, # Renamed
{"key": None, "name": "Finished", "cols": (5, 5, 5)}, # New, reused name
]
assert theProject.setStatusColours(None, None) is False
assert theProject.setStatusColours([], []) is False
assert theProject.setStatusColours(newList, []) is True
assert project.setStatusColours(None, None) is False # type: ignore
assert project.setStatusColours([], []) is False
assert project.setStatusColours(newList, []) is True
assert theProject.data.itemStatus.name(statusKeys[0]) == "New"
assert theProject.data.itemStatus.name(statusKeys[1]) == "Draft"
assert theProject.data.itemStatus.name(statusKeys[2]) == "Note"
assert theProject.data.itemStatus.name(statusKeys[3]) == "Edited"
assert theProject.data.itemStatus.cols(statusKeys[0]) == (1, 1, 1)
assert theProject.data.itemStatus.cols(statusKeys[1]) == (2, 2, 2)
assert theProject.data.itemStatus.cols(statusKeys[2]) == (3, 3, 3)
assert theProject.data.itemStatus.cols(statusKeys[3]) == (4, 4, 4)
assert project.data.itemStatus.name(statusKeys[0]) == "New"
assert project.data.itemStatus.name(statusKeys[1]) == "Draft"
assert project.data.itemStatus.name(statusKeys[2]) == "Note"
assert project.data.itemStatus.name(statusKeys[3]) == "Edited"
assert project.data.itemStatus.cols(statusKeys[0]) == (1, 1, 1)
assert project.data.itemStatus.cols(statusKeys[1]) == (2, 2, 2)
assert project.data.itemStatus.cols(statusKeys[2]) == (3, 3, 3)
assert project.data.itemStatus.cols(statusKeys[3]) == (4, 4, 4)
# Check the new entry
lastKey = theProject.data.itemStatus.check("s000010")
lastKey = project.data.itemStatus.check("s000010")
assert lastKey == "s000010"
assert theProject.data.itemStatus.name(lastKey) == "Finished"
assert theProject.data.itemStatus.cols(lastKey) == (5, 5, 5)
assert project.data.itemStatus.name(lastKey) == "Finished"
assert project.data.itemStatus.cols(lastKey) == (5, 5, 5)
# Delete last entry
assert theProject.setStatusColours([], [lastKey]) is True
assert theProject.data.itemStatus.name(lastKey) == "New"
assert project.setStatusColours([], [lastKey]) is True
assert project.data.itemStatus.name(lastKey) == "New"
# Change Importance
# =================
fHandle = theProject.newFile("Jane Doe", C.hCharRoot)
theProject.tree[fHandle].setImport(importKeys[3])
fHandle = project.newFile("Jane Doe", C.hCharRoot)
project.tree[fHandle].setImport(importKeys[3]) # type: ignore
assert theProject.tree[fHandle].itemImport == importKeys[3]
assert project.tree[fHandle].itemImport == importKeys[3] # type: ignore
newList = [
{"key": importKeys[0], "name": "New", "cols": (1, 1, 1)},
{"key": importKeys[1], "name": "Minor", "cols": (2, 2, 2)},
@@ -417,44 +430,44 @@ def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd):
{"key": importKeys[3], "name": "Min", "cols": (4, 4, 4)},
{"key": None, "name": "Max", "cols": (5, 5, 5)},
]
assert theProject.setImportColours(None, None) is False
assert theProject.setImportColours([], []) is False
assert theProject.setImportColours(newList, []) is True
assert project.setImportColours(None, None) is False # type: ignore
assert project.setImportColours([], []) is False
assert project.setImportColours(newList, []) is True
assert theProject.data.itemImport.name(importKeys[0]) == "New"
assert theProject.data.itemImport.name(importKeys[1]) == "Minor"
assert theProject.data.itemImport.name(importKeys[2]) == "Major"
assert theProject.data.itemImport.name(importKeys[3]) == "Min"
assert theProject.data.itemImport.cols(importKeys[0]) == (1, 1, 1)
assert theProject.data.itemImport.cols(importKeys[1]) == (2, 2, 2)
assert theProject.data.itemImport.cols(importKeys[2]) == (3, 3, 3)
assert theProject.data.itemImport.cols(importKeys[3]) == (4, 4, 4)
assert project.data.itemImport.name(importKeys[0]) == "New"
assert project.data.itemImport.name(importKeys[1]) == "Minor"
assert project.data.itemImport.name(importKeys[2]) == "Major"
assert project.data.itemImport.name(importKeys[3]) == "Min"
assert project.data.itemImport.cols(importKeys[0]) == (1, 1, 1)
assert project.data.itemImport.cols(importKeys[1]) == (2, 2, 2)
assert project.data.itemImport.cols(importKeys[2]) == (3, 3, 3)
assert project.data.itemImport.cols(importKeys[3]) == (4, 4, 4)
# Check the new entry
lastKey = theProject.data.itemImport.check("i000012")
lastKey = project.data.itemImport.check("i000012")
assert lastKey == "i000012"
assert theProject.data.itemImport.name(lastKey) == "Max"
assert theProject.data.itemImport.cols(lastKey) == (5, 5, 5)
assert project.data.itemImport.name(lastKey) == "Max"
assert project.data.itemImport.cols(lastKey) == (5, 5, 5)
# Delete last entry
assert theProject.setImportColours([], [lastKey]) is True
assert theProject.data.itemImport.name(lastKey) == "New"
assert project.setImportColours([], [lastKey]) is True
assert project.data.itemImport.name(lastKey) == "New"
# Delete Status/Import
# ====================
theProject.data.itemStatus.resetCounts()
for key in list(theProject.data.itemStatus.keys()):
assert theProject.data.itemStatus.remove(key) is True
project.data.itemStatus.resetCounts()
for key in list(project.data.itemStatus.keys()):
assert project.data.itemStatus.remove(key) is True
theProject.data.itemImport.resetCounts()
for key in list(theProject.data.itemImport.keys()):
assert theProject.data.itemImport.remove(key) is True
project.data.itemImport.resetCounts()
for key in list(project.data.itemImport.keys()):
assert project.data.itemImport.remove(key) is True
assert len(theProject.data.itemStatus) == 0
assert len(theProject.data.itemImport) == 0
assert theProject.saveProject() is True
theProject.closeProject()
assert len(project.data.itemStatus) == 0
assert len(project.data.itemImport) == 0
assert project.saveProject() is True
project.closeProject()
# END Test testCoreProject_StatusImport
@@ -462,96 +475,96 @@ def testCoreProject_StatusImport(mockGUI, fncPath, mockRnd):
@pytest.mark.core
def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
"""Test other project class methods and functions."""
theProject = NWProject()
buildTestProject(theProject, fncPath)
project = NWProject()
buildTestProject(project, fncPath)
# Project Name
theProject.data.setName(" A Name ")
assert theProject.data.name == "A Name"
project.data.setName(" A Name ")
assert project.data.name == "A Name"
# Project Title
theProject.data.setTitle(" A Title ")
assert theProject.data.title == "A Title"
project.data.setTitle(" A Title ")
assert project.data.title == "A Title"
# Project Author
theProject.data.setAuthor(" Jane\tDoe ")
assert theProject.data.author == "Jane Doe"
project.data.setAuthor(" Jane\tDoe ")
assert project.data.author == "Jane Doe"
# Edit Time
theProject.data.setEditTime(1234)
theProject._session._start = 1600000000
project.data.setEditTime(1234)
project._session._start = 1600000000
with monkeypatch.context() as mp:
mp.setattr("novelwriter.core.project.time", lambda: 1600005600)
assert theProject.currentEditTime == 6834
assert project.currentEditTime == 6834
# Trash folder
# Should create on first call, and just returned on later calls
hTrash = "0000000000010"
assert theProject.tree[hTrash] is None
assert theProject.trashFolder() == hTrash
assert theProject.trashFolder() == hTrash
assert project.tree[hTrash] is None
assert project.trashFolder() == hTrash
assert project.trashFolder() == hTrash
# Spell check
theProject.setProjectChanged(False)
theProject.data.setSpellCheck(True)
theProject.data.setSpellCheck(False)
assert theProject.projChanged is True
assert theProject.projOpened > 0
project.setProjectChanged(False)
project.data.setSpellCheck(True)
project.data.setSpellCheck(False)
assert project.projChanged is True
assert project.projOpened > 0
# Spell language
theProject.setProjectChanged(False)
assert theProject.data.spellLang is None
theProject.data.setSpellLang(None)
assert theProject.data.spellLang is None
theProject.data.setSpellLang("None") # Should be interpreted as None
assert theProject.data.spellLang is None
theProject.data.setSpellLang("en_GB")
assert theProject.data.spellLang == "en_GB"
assert theProject.projChanged is True
project.setProjectChanged(False)
assert project.data.spellLang is None
project.data.setSpellLang(None)
assert project.data.spellLang is None
project.data.setSpellLang("None") # Should be interpreted as None
assert project.data.spellLang is None
project.data.setSpellLang("en_GB")
assert project.data.spellLang == "en_GB"
assert project.projChanged is True
# Project Language
theProject.setProjectChanged(False)
theProject.data.setLanguage("en")
theProject.setProjectLang(None)
assert theProject.data.language is None
theProject.setProjectLang("en_GB")
assert theProject.data.language == "en_GB"
project.setProjectChanged(False)
project.data.setLanguage("en")
project.setProjectLang(None)
assert project.data.language is None
project.setProjectLang("en_GB")
assert project.data.language == "en_GB"
# Language Lookup
assert theProject.localLookup(1) == "One"
assert theProject.localLookup(10) == "Ten"
assert project.localLookup(1) == "One"
assert project.localLookup(10) == "Ten"
# Set invalid language
theProject.data.setLanguage("foo")
theProject._loadProjectLocalisation()
assert theProject.localLookup(1) == "One"
assert theProject.localLookup(10) == "Ten"
project.data.setLanguage("foo")
project._loadProjectLocalisation()
assert project.localLookup(1) == "One"
assert project.localLookup(10) == "Ten"
# Block reading language data
theProject.data.setLanguage("en")
project.data.setLanguage("en")
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
theProject._loadProjectLocalisation()
assert theProject.localLookup(1) == "One"
assert theProject.localLookup(10) == "Ten"
project._loadProjectLocalisation()
assert project.localLookup(1) == "One"
assert project.localLookup(10) == "Ten"
# Last edited
theProject.setProjectChanged(False)
theProject._data.setLastHandle("0123456789abc", "editor")
assert theProject._data.getLastHandle("editor") == "0123456789abc"
assert theProject.projChanged
project.setProjectChanged(False)
project._data.setLastHandle("0123456789abc", "editor")
assert project._data.getLastHandle("editor") == "0123456789abc"
assert project.projChanged
# Last viewed
theProject.setProjectChanged(False)
theProject._data.setLastHandle("0123456789abc", "viewer")
assert theProject._data.getLastHandle("viewer") == "0123456789abc"
assert theProject.projChanged
project.setProjectChanged(False)
project._data.setLastHandle("0123456789abc", "viewer")
assert project._data.getLastHandle("viewer") == "0123456789abc"
assert project.projChanged
# Autoreplace
theProject.setProjectChanged(False)
theProject.data.setAutoReplace({"A": "B", "C": "D"})
assert theProject.data.autoReplace == {"A": "B", "C": "D"}
assert theProject.projChanged
# Auto Replace
project.setProjectChanged(False)
project.data.setAutoReplace({"A": "B", "C": "D"})
assert project.data.autoReplace == {"A": "B", "C": "D"}
assert project.projChanged
# Change project tree order
oldOrder = [
@@ -564,11 +577,11 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd):
"0000000000008", "0000000000009", "000000000000a",
"000000000000e", "000000000000f",
]
assert theProject.tree.handles() == oldOrder
theProject.setTreeOrder(newOrder)
assert theProject.tree.handles() == newOrder
theProject.setTreeOrder(oldOrder)
assert theProject.tree.handles() == oldOrder
assert project.tree.handles() == oldOrder
project.setTreeOrder(newOrder)
assert project.tree.handles() == newOrder
project.setTreeOrder(oldOrder)
assert project.tree.handles() == oldOrder
# END Test testCoreProject_Methods
@@ -580,38 +593,38 @@ def testCoreProject_Backup(monkeypatch, mockGUI, fncPath, tstPaths):
backup file and checks that the project XML file is identical to
the original file.
"""
theProject = NWProject()
project = NWProject()
# No Project
assert theProject.backupProject(doNotify=False) is False
assert project.backupProject(doNotify=False) is False
buildTestProject(theProject, fncPath)
buildTestProject(project, fncPath)
# Invalid Settings
# ================
# Missing project name
CONFIG._backupPath = tstPaths.tmpDir
theProject.data.setName("")
assert theProject.backupProject(doNotify=False) is False
project.data.setName("")
assert project.backupProject(doNotify=False) is False
# Valid Settings
# ==============
CONFIG._backupPath = tstPaths.tmpDir
theProject.data.setName("Test Minimal")
project.data.setName("Test Minimal")
# Can't make folder
with monkeypatch.context() as mp:
mp.setattr("pathlib.Path.mkdir", causeOSError)
assert theProject.backupProject(doNotify=False) is False
assert project.backupProject(doNotify=False) is False
# Can't write archive
with monkeypatch.context() as mp:
mp.setattr("zipfile.ZipFile.write", causeOSError)
assert theProject.backupProject(doNotify=False) is False
assert project.backupProject(doNotify=False) is False
# Test correct settings
assert theProject.backupProject(doNotify=True) is True
assert project.backupProject(doNotify=True) is True
theFiles = sorted((tstPaths.tmpDir / "Test Minimal").iterdir())
assert len(theFiles) in (1, 2) # Sometimes 2 due to clock tick
+20 -19
View File
@@ -25,6 +25,7 @@ import pytest
from shutil import copyfile
from datetime import datetime
from novelwriter.constants import nwFiles
from tools import cmpFiles, writeFile
from mocked import causeOSError
@@ -62,7 +63,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath):
xmlReader = ProjectXMLReader(xmlFile)
assert xmlReader.state == XMLReadState.NO_ACTION
data = NWProjectData(MockProject())
data = NWProjectData(MockProject()) # type: ignore
content = []
# With no valid files, the read should fail
@@ -132,7 +133,7 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath):
assert xmlReader.state == XMLReadState.WAS_LEGACY
# Reset data objects
data = NWProjectData(MockProject())
data = NWProjectData(MockProject()) # type: ignore
content = []
# Parse a valid, complete file
@@ -215,13 +216,13 @@ def testCoreProjectXML_ReadCurrent(monkeypatch, tstPaths, fncPath):
mockProject = MockProject()
mockProject.__setattr__("data", data)
for entry in content:
item = NWItem(mockProject, "0000000000000")
item = NWItem(mockProject, "0000000000000") # type: ignore
item.unpack(entry)
packedContent.append(item.pack())
# Save the project again, which should produce an identical project xml
timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp())
xmlWriter = ProjectXMLWriter(fncPath)
xmlWriter = ProjectXMLWriter(fncPath / nwFiles.PROJ_FILE)
# Fail saving
with monkeypatch.context() as mp:
@@ -254,7 +255,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd):
xmlReader = ProjectXMLReader(xmlFile)
assert xmlReader.state == XMLReadState.NO_ACTION
data = NWProjectData(MockProject())
data = NWProjectData(MockProject()) # type: ignore
content = []
assert xmlReader.read(data, content) is True
@@ -335,7 +336,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data)
status = {}
for entry in content:
item = NWItem(mockProject, "0000000000000")
item = NWItem(mockProject, "0000000000000") # type: ignore
item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack())
@@ -367,7 +368,7 @@ def testCoreProjectXML_ReadLegacy10(tstPaths, fncPath, mockRnd):
# Save the project again, which should produce an identical project xml
timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp())
xmlWriter = ProjectXMLWriter(fncPath)
xmlWriter = ProjectXMLWriter(fncPath / nwFiles.PROJ_FILE)
data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")
assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True
testFile = tstPaths.outDir / "projectXML_ReadLegacy10.nwx"
@@ -389,7 +390,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd):
xmlReader = ProjectXMLReader(xmlFile)
assert xmlReader.state == XMLReadState.NO_ACTION
data = NWProjectData(MockProject())
data = NWProjectData(MockProject()) # type: ignore
content = []
assert xmlReader.read(data, content) is True
@@ -470,7 +471,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data)
status = {}
for entry in content:
item = NWItem(mockProject, "0000000000000")
item = NWItem(mockProject, "0000000000000") # type: ignore
item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack())
@@ -502,7 +503,7 @@ def testCoreProjectXML_ReadLegacy11(tstPaths, fncPath, mockRnd):
# Save the project again, which should produce an identical project xml
timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp())
xmlWriter = ProjectXMLWriter(fncPath)
xmlWriter = ProjectXMLWriter(fncPath / nwFiles.PROJ_FILE)
data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")
assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True
testFile = tstPaths.outDir / "projectXML_ReadLegacy11.nwx"
@@ -524,7 +525,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd):
xmlReader = ProjectXMLReader(xmlFile)
assert xmlReader.state == XMLReadState.NO_ACTION
data = NWProjectData(MockProject())
data = NWProjectData(MockProject()) # type: ignore
content = []
assert xmlReader.read(data, content) is True
@@ -605,7 +606,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data)
status = {}
for entry in content:
item = NWItem(mockProject, "0000000000000")
item = NWItem(mockProject, "0000000000000") # type: ignore
item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack())
@@ -640,7 +641,7 @@ def testCoreProjectXML_ReadLegacy12(tstPaths, fncPath, mockRnd):
# Save the project again, which should produce an identical project xml
timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp())
xmlWriter = ProjectXMLWriter(fncPath)
xmlWriter = ProjectXMLWriter(fncPath / nwFiles.PROJ_FILE)
data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")
assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True
testFile = tstPaths.outDir / "projectXML_ReadLegacy12.nwx"
@@ -662,7 +663,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd):
xmlReader = ProjectXMLReader(xmlFile)
assert xmlReader.state == XMLReadState.NO_ACTION
data = NWProjectData(MockProject())
data = NWProjectData(MockProject()) # type: ignore
content = []
assert xmlReader.read(data, content) is True
@@ -743,7 +744,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data)
status = {}
for entry in content:
item = NWItem(mockProject, "0000000000000")
item = NWItem(mockProject, "0000000000000") # type: ignore
item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack())
@@ -778,7 +779,7 @@ def testCoreProjectXML_ReadLegacy13(tstPaths, fncPath, mockRnd):
# Save the project again, which should produce an identical project xml
timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp())
xmlWriter = ProjectXMLWriter(fncPath)
xmlWriter = ProjectXMLWriter(fncPath / nwFiles.PROJ_FILE)
data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")
assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True
testFile = tstPaths.outDir / "projectXML_ReadLegacy13.nwx"
@@ -800,7 +801,7 @@ def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd):
xmlReader = ProjectXMLReader(xmlFile)
assert xmlReader.state == XMLReadState.NO_ACTION
data = NWProjectData(MockProject())
data = NWProjectData(MockProject()) # type: ignore
content = []
assert xmlReader.read(data, content) is True
@@ -881,7 +882,7 @@ def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd):
mockProject.__setattr__("data", data)
status = {}
for entry in content:
item = NWItem(mockProject, "0000000000000")
item = NWItem(mockProject, "0000000000000") # type: ignore
item.unpack(entry)
status[item.itemHandle] = item.getImportStatus(incIcon=False)[0]
packedContent.append(item.pack())
@@ -918,7 +919,7 @@ def testCoreProjectXML_ReadLegacy14(tstPaths, fncPath, mockRnd):
# Save the project again, which should produce an identical project xml
timeStamp = int(datetime.fromisoformat(xmlReader.timeStamp).timestamp())
xmlWriter = ProjectXMLWriter(fncPath)
xmlWriter = ProjectXMLWriter(fncPath / nwFiles.PROJ_FILE)
data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")
assert xmlWriter.write(data, packedContent, timeStamp, 1000) is True
testFile = tstPaths.outDir / "projectXML_ReadLegacy14.nwx"
+181 -94
View File
@@ -26,13 +26,14 @@ import pytest
from pathlib import Path
from zipfile import ZipFile
from tools import C, buildTestProject, writeFile
from tools import C, buildTestProject
from mocked import causeOSError
from novelwriter import CONFIG
from novelwriter.constants import nwFiles
from novelwriter.core.project import NWProject
from novelwriter.core.storage import NWStorage, _LegacyStorage
from novelwriter.core.storage import NWStorage, NWStorageOpen, _LegacyStorage
from novelwriter.core.document import NWDocument
from novelwriter.core.projectxml import ProjectXMLReader, ProjectXMLWriter
@@ -42,21 +43,19 @@ class MockProject:
@pytest.mark.core
def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd):
"""Test opening a project in a folder."""
theProject = NWProject()
mockRnd.reset()
buildTestProject(theProject, fncPath)
theProject.closeProject()
def testCoreStorage_CreateNewProject(mockGUI, fncPath):
"""Test creating a project in a folder."""
project = NWProject()
# Create instance
storage = NWStorage(theProject)
storage = NWStorage(project)
# Check defaults
assert storage.storagePath is None
assert storage.runtimePath is None
assert storage.contentPath is None
assert storage._openMode == NWStorage.MODE_INACTIVE
assert storage._ready is False
# Check closed project return values
assert storage.isOpen() is False
@@ -66,52 +65,137 @@ def testCoreStorage_OpenProjectInPlace(mockGUI, fncPath, mockRnd):
assert storage.getMetaFile("file") is None
assert storage.scanContent() == []
# Open project as a new project should fail
assert storage.openProjectInPlace(fncPath, newProject=True) is False
# Cannot prepare a non-empty folder
(fncPath / "foobar.txt").touch()
assert storage.createNewProject(fncPath) is False
# Opening as a non-new project is fine
assert storage.openProjectInPlace(fncPath, newProject=False) is True
# Try creating in a non-existent subfolder instead
assert storage.createNewProject(fncPath / "project1") is True
assert (fncPath / "project1").is_dir()
assert (fncPath / "project1" / "meta").is_dir()
assert (fncPath / "project1" / "content").is_dir()
# Opening the project file is also fine
assert storage.openProjectInPlace(fncPath / nwFiles.PROJ_FILE, newProject=False) is True
# However, the parent folder must exist
assert storage.createNewProject(fncPath / "foobar" / "project1") is False
assert isinstance(storage.exc, FileNotFoundError)
# Opening as a non-new project on a non-existing folder should fail
assert storage.openProjectInPlace(fncPath / "foobar", newProject=False) is False
project.closeProject()
# Check settings
assert storage.storagePath == fncPath
assert storage.runtimePath == fncPath
assert storage.contentPath == fncPath / "content"
assert storage._openMode == NWStorage.MODE_INPLACE
# END Test testCoreStorage_CreateNewProject
# Open the project itself
theProject.openProject(fncPath)
storage = theProject.storage
# Get XML components
assert isinstance(storage.getXmlReader(), ProjectXMLReader)
assert isinstance(storage.getXmlWriter(), ProjectXMLWriter)
@pytest.mark.core
def testCoreStorage_InitProjectStorage(mockGUI, fncPath, mockRnd):
"""Test initialising a project in a folder."""
project = NWProject()
# Get content
assert sorted(storage.scanContent()) == [C.hTitlePage, C.hChapterDoc, C.hSceneDoc]
# Create instance
storage = NWStorage(project)
# Get document
assert storage.getDocument(C.hSceneDoc).readDocument() == "### New Scene\n\n"
# Check defaults
assert storage.storagePath is None
assert storage.runtimePath is None
assert storage.contentPath is None
assert storage._openMode == NWStorage.MODE_INACTIVE
assert storage._ready is False
# Get paths
assert storage.getMetaFile("stuff") == fncPath / "meta" / "stuff"
# Clean up
theProject.closeProject()
# Check closed project return values (again)
# 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.scanContent() == []
# END Test testCoreStorage_ProjectInPlace
# Create a new project
buildTestProject(project, fncPath)
# Init with the wrong file
assert storage.initProjectStorage(fncPath / "foobar.txt") == NWStorageOpen.UNKOWN
storage._clearLockFile()
storage.clear()
# Init with the user's home dir
assert storage.initProjectStorage(Path.home()) == NWStorageOpen.UNKOWN
storage._clearLockFile()
storage.clear()
# Init with the project folder is OK
assert storage.initProjectStorage(fncPath) == NWStorageOpen.READY
assert storage.runtimePath == fncPath
assert storage.storagePath == fncPath
assert storage.contentPath == fncPath / "content"
assert storage._openMode == NWStorage.MODE_INPLACE
storage._clearLockFile()
storage.clear()
# Init with the project main file is OK
assert storage.initProjectStorage(fncPath / nwFiles.PROJ_FILE) == NWStorageOpen.READY
assert storage.runtimePath == fncPath
assert storage.storagePath == fncPath
assert storage.contentPath == fncPath / "content"
assert storage._openMode == NWStorage.MODE_INPLACE
storage._clearLockFile()
storage.clear()
# Open twice, where second should fail due to lockfile
assert storage.initProjectStorage(fncPath) == NWStorageOpen.READY
assert storage.initProjectStorage(fncPath) == NWStorageOpen.LOCKED
assert isinstance(storage.lockStatus, list)
assert len(storage.lockStatus) == 4
# But open again with clear lock file flag set is OK
assert storage.initProjectStorage(fncPath, clearLock=True) == NWStorageOpen.READY
assert storage.lockStatus is None
# We should now have access to project resources
assert isinstance(storage.getXmlReader(), ProjectXMLReader)
assert isinstance(storage.getXmlWriter(), ProjectXMLWriter)
assert isinstance(storage.getDocument(C.hSceneDoc), NWDocument)
assert repr(storage.getDocument(C.hSceneDoc)) == f"<NWDocument handle={C.hSceneDoc}>"
project.closeProject()
# END Test testCoreStorage_InitProjectStorage
@pytest.mark.core
def testCoreStorage_InitProjectStorage_Invalid(mockGUI, fncPath):
"""Test initialising a project in an invalid folder."""
project = NWProject()
# Create instance
storage = NWStorage(project)
# Check defaults
assert storage.storagePath is None
assert storage.runtimePath is None
assert storage.contentPath is None
assert storage._openMode == NWStorage.MODE_INACTIVE
assert storage._ready is False
# 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.scanContent() == []
# Populate folder with invalid files
(fncPath / "meta").touch() # These are now files but should be folders
(fncPath / "content").touch() # These are now files but should be folders
# Try opening the folder, but there is no project file
assert storage.initProjectStorage(fncPath) == NWStorageOpen.NOT_FOUND
# Add the project file, and we should now fail on the folders
(fncPath / nwFiles.PROJ_FILE).touch()
assert storage.initProjectStorage(fncPath) == NWStorageOpen.FAILED
project.closeProject()
# END Test testCoreStorage_InitProjectStorage_Invalid
@pytest.mark.core
@@ -123,54 +207,55 @@ def testCoreStorage_LockFile(monkeypatch, fncPath):
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
assert storage._readLockFile() is None
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() == []
storage._readLockFile()
assert storage.lockStatus is None
# Write lockfile fails
with monkeypatch.context() as mp:
mp.setattr("pathlib.Path.write_text", causeOSError)
assert storage.writeLockFile() is False
assert storage._writeLockFile() is False
assert not lockFilePath.exists()
# Successful write
assert storage.writeLockFile() is True
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"]
storage._readLockFile()
assert storage.lockStatus == ["ERROR", "ERROR", "ERROR", "ERROR"]
assert lockFilePath.exists()
# Successful read
assert storage.readLockFile() == [
CONFIG.hostName,
CONFIG.osType,
CONFIG.kernelVer,
"1000",
storage._readLockFile()
assert storage.lockStatus == [
CONFIG.hostName, CONFIG.osType, CONFIG.kernelVer, "1000",
]
# Write an invalid lockfile
writeFile(lockFilePath, "a;b;c")
assert storage.readLockFile() == ["ERROR"]
lockFilePath.write_text("a;b;c")
storage._readLockFile()
assert storage.lockStatus is None
# Fail to remove lockfile
with monkeypatch.context() as mp:
mp.setattr("pathlib.Path.unlink", causeOSError)
assert storage.clearLockFile() is False
assert storage._clearLockFile() is False
assert lockFilePath.exists()
# Successful remove
assert storage.clearLockFile() is True
assert storage._clearLockFile() is True
assert not lockFilePath.exists()
# END Test testCoreStorage_LockFile
@@ -212,46 +297,46 @@ def testCoreStorage_ZipIt(monkeypatch, mockGUI, fncPath, tstPaths, mockRnd):
# END Test testCoreStorage_ZipIt
@pytest.mark.core
def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
"""Test the project path preparation functions."""
storage = NWStorage(MockProject()) # type: ignore
assert storage.isOpen() is False
# @pytest.mark.core
# def testCoreStorage_PrepareStorage(monkeypatch, fncPath):
# """Test the project path preparation functions."""
# storage = NWStorage(MockProject()) # type: ignore
# assert storage.isOpen() is False
# No path set
assert storage._prepareStorage() 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
# # 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
# # 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 / "meta").exists()
assert not (fncPath / "cache").exists() # Removed in 2.1b1
# # Set up the folder
# storage._runtimePath = fncPath
# assert storage._prepareStorage(checkLegacy=False) is True
# assert (fncPath / "content").exists()
# assert (fncPath / "meta").exists()
# assert not (fncPath / "cache").exists() # Removed in 2.1b1
# Add a legacy folder
storage._runtimePath = fncPath
dataDir = fncPath / "data_0"
dataDir.mkdir()
assert storage._prepareStorage(checkLegacy=True) is True
assert not dataDir.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
# # We cannot add a new project here
# storage._runtimePath = fncPath
# assert storage._prepareStorage(checkLegacy=False, newProject=True) is False
# END Test testCoreStorage_PrepareStorage
# # END Test testCoreStorage_PrepareStorage
@pytest.mark.core
@@ -261,7 +346,8 @@ def testCoreStorage_LegacyDataFolder(monkeypatch, fncPath):
storage = NWStorage(project) # type: ignore
assert storage.isOpen() is False
storage._runtimePath = fncPath
assert storage._prepareStorage() is True
(fncPath / nwFiles.PROJ_FILE).touch()
storage.initProjectStorage(fncPath)
legacy = _LegacyStorage(project) # type: ignore
data = []
@@ -290,8 +376,8 @@ def testCoreStorage_LegacyDataFolder(monkeypatch, fncPath):
legacy.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()
for i in range(9):
assert (fncPath / "content" / f"{i}00000000000{i}.nwd").exists()
# Folders 0 to 6 should be deleted
for i in range(7):
@@ -314,7 +400,7 @@ def testCoreStorage_LegacyDataFolder(monkeypatch, fncPath):
assert not (fncPath / "content" / "9000000000009.nwd").exists()
# Run the remaining through the prepare storage call
assert storage._prepareStorage(checkLegacy=True) is True
assert storage.initProjectStorage(fncPath, clearLock=True) == NWStorageOpen.READY
for c in "0123456789abcdef":
assert (fncPath / "content" / f"{c}00000000000{c}.nwd").exists()
@@ -328,7 +414,8 @@ def testCoreStorage_DeprecatedFiles(monkeypatch, fncPath):
storage = NWStorage(project) # type: ignore
assert storage.isOpen() is False
storage._runtimePath = fncPath
assert storage._prepareStorage() is True
(fncPath / nwFiles.PROJ_FILE).touch()
storage.initProjectStorage(fncPath)
legacy = _LegacyStorage(project) # type: ignore
# Files/Folders to be Deleted or Renamed
+1 -1
View File
@@ -171,7 +171,7 @@ def buildTestProject(obj, projPath):
nwGUI = obj
project = SHARED.project
project.storage.openProjectInPlace(projPath)
project.storage.createNewProject(projPath)
project.setDefaultStatusImport()
project.data.setUuid("d0f3fe10-c6e6-4310-8bfd-181eb4224eed")