diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md index f7b7cc08..cf59ae2a 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -5,20 +5,12 @@ title: "" labels: bug --- -**Describe the Bug** -A clear and concise description of what the bug is. +**Note**: Issues without a description will not be considered. +Please also check if an issue already exists on this problem. +Please provide a description covering the following points, if applicable: -**To Reproduce** -Steps to reproduce the behaviour: - -**Expected Behaviour** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Error Message** -If the error dialog popped up, copy/paste the content here. - -**Additional Context** -Add any other context about the problem here. +* A clear and concise description of what the bug is. +* Steps to reproduce the behaviour +* A clear and concise description of what you expected to happen. +* If applicable, add screenshots to help explain your problem. +* If the error dialog popped up, copy/paste the content here. diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md index 761f0bc9..93a61e93 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -5,14 +5,11 @@ title: "" labels: enhancement --- -**Is your feature request related to a problem? Please describe:** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +**Note**: Feature requests without a description will not be considered. +Please also check if an issue already exists on this feature. +Please provide a description covering the following points, if applicable: -**Describe the solution you'd like:** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered:** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context:** -Add any other context or screenshots about the feature request here. +* A clear and concise description of what the problem is. Ex. I'm always frustrated when ... +* A clear and concise description of what you want to happen. +* A clear and concise description of any alternative solutions or features you've considered. +* If applicable, add screenshots to help explain your request. diff --git a/CHANGELOG.md b/CHANGELOG.md index bd5028a8..7271ab65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# novelWriter ChangeLog +# novelWriter Change Log ## Version 1.0 Release Candidate 2 [2020-11-29] diff --git a/nw/__init__.py b/nw/__init__.py index f3681594..cb4eb949 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -58,20 +58,21 @@ from nw.config import Config # __package__ = "nw" +__copyright__ = "Copyright 2018–2020, Veronica Berglyd Olsen" __license__ = "GPLv3" __author__ = "Veronica Berglyd Olsen" __maintainer__ = "Veronica Berglyd Olsen" -__copyright__ = "Copyright 2018–2020, Veronica Berglyd Olsen" __email__ = "code@vkbo.net" __version__ = "1.0rc2" __hexversion__ = "0x010000c2" -__date__ = "2020-11-29" +__date__ = "2020-12-13" __status__ = "Beta" -__isstable__ = False +__domain__ = "novelwriter.io" __url__ = "https://novelwriter.io" __sourceurl__ = "https://github.com/vkbo/novelWriter" __issuesurl__ = "https://github.com/vkbo/novelWriter/issues" -__domain__ = "novelwriter.io" +__helpurl__ = "https://github.com/vkbo/novelWriter/discussions" +__releaseurl__ = "https://github.com/vkbo/novelWriter/releases/latest" __docurl__ = "https://novelwriter.readthedocs.io" __credits__ = [ "Veronica Berglyd Olsen (developer)", @@ -246,19 +247,20 @@ def main(sysArgs=None): errorCode |= 32 if errorData: - if not testMode: - errApp = QApplication([]) - errMsg = QErrorMessage() - errMsg.resize(500, 300) - errMsg.showMessage(( - "

A critical error has been encountered

" - "

novelWriter cannot start due to the following issues:

" - "

 - %s

" - "

Shutting down ...

" - ) % ( - "
 - ".join(errorData) - )) - errApp.exec_() + errApp = QApplication([]) + errMsg = QErrorMessage() + errMsg.resize(500, 300) + errMsg.showMessage(( + "

A critical error has been encountered

" + "

novelWriter cannot start due to the following issues:

" + "

 - %s

" + "

Shutting down ...

" + ) % ( + "
 - ".join(errorData) + )) + for errMsg in errorData: + logger.critical(errMsg) + errApp.exec_() sys.exit(errorCode) # Finish initialising config @@ -293,4 +295,4 @@ def main(sysArgs=None): nwGUI = GuiMain() sys.exit(nwApp.exec_()) - return +# END Function main diff --git a/nw/assets/icons/novelwriter.ico b/nw/assets/icons/novelwriter.ico index 470ebe72..df883296 100644 Binary files a/nw/assets/icons/novelwriter.ico and b/nw/assets/icons/novelwriter.ico differ diff --git a/nw/assets/icons/x-novelwriter-project.ico b/nw/assets/icons/x-novelwriter-project.ico new file mode 100644 index 00000000..b349aa30 Binary files /dev/null and b/nw/assets/icons/x-novelwriter-project.ico differ diff --git a/nw/assets/icons/x-novelwriter-project.svg b/nw/assets/icons/x-novelwriter-project.svg new file mode 100644 index 00000000..08546d15 --- /dev/null +++ b/nw/assets/icons/x-novelwriter-project.svg @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/nw/assets/text/release_notes.htm b/nw/assets/text/release_notes.htm new file mode 100644 index 00000000..d9818aa9 --- /dev/null +++ b/nw/assets/text/release_notes.htm @@ -0,0 +1,35 @@ + + + +

Release Notes for 1.0 RC1

+

This is the first release candidate for the upcoming release of novelWriter 1.0.

+

Since the fifth beta release about four weeks ago, not much has been changed in novelWriter. A +few minor tweaks have been made to the GUI.

+

A number of features and tools are now automatically switched off when there is no project or +document open for those features to act upon. Previously, this was a bit inconsistent, although no +serious bugs have been reported or encountered.

+

Most of the minor changes in this release should not be noticeable to most users. However, there +are a couple of noticeable changes.

+

Typewriter Mode

+

The "Typewriter Mode" of the editor has been improved. Essentially, this feature is a sort of +smart scroll. It tries to keep the cursor stationary in the vertical direction, and will try to +scroll the document up when the cursor skips to a new line while typing (or down in case of +backspaces). This is similar to the way a typewriter scrolls the paper when hitting the return key. +It improves the writing experience as the current active line will stay at the same eye height +level on the screen.

+

Previously, the feature would lock the cursor to a given vertical position defined by the user. +Now, instead, the cursor will remain stationary in the vertical direction at any position the user +sets it to by mouse click or keyboard navigation. The user can define a minimum distance from the +top where this feature is activated. These changes makes it more flexible in terms of where the +focus is in the editor. The feature can be controlled from the main Preferences.

+

Switching Syntax Theme

+

It is now possible to switch syntax highlighting theme without restarting novelWriter. +Previously, changing the theme would only half-way update the document, header and footer +background and text colours. The new settings would not be fully applied until the application was +shut down and started again, thus making it a bit tedious to look through syntax themes to find the +one you want.

+

Switching main GUI theme still requires a restart.

+

The full changelog is available +here.

+ + diff --git a/nw/assets/themes/syntax/default_light.conf b/nw/assets/themes/syntax/default_light.conf index 389ba6cb..908fd7cb 100644 --- a/nw/assets/themes/syntax/default_light.conf +++ b/nw/assets/themes/syntax/default_light.conf @@ -10,7 +10,7 @@ licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ background = 255, 255, 255 text = 0, 0, 0 link = 0, 0, 200 -headertext = 0, 100, 00 +headertext = 0, 100, 0 headertag = 50, 100, 50 emphasis = 150, 110, 30 straightquotes = 200, 0, 0 diff --git a/nw/assets/themes/syntax/grey_dark.conf b/nw/assets/themes/syntax/grey_dark.conf new file mode 100644 index 00000000..a6bc0420 --- /dev/null +++ b/nw/assets/themes/syntax/grey_dark.conf @@ -0,0 +1,25 @@ +[Main] +name = Grey Dark +author = Veronica Berglyd Olsen +credit = Veronica Berglyd Olsen +url = https://github.com/vkbo/novelWriter +license = CC BY-SA 4.0 +licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ + +[Syntax] +background = 54, 54, 54 +text = 200, 200, 200 +link = 200, 200, 200 +headertext = 225, 225, 225 +headertag = 225, 225, 225 +emphasis = 200, 200, 200 +straightquotes = 200, 200, 200 +doublequotes = 200, 200, 200 +singlequotes = 200, 200, 200 +hidden = 150, 150, 150 +keyword = 225, 225, 225 +value = 200, 200, 200 +spellcheckline = 200, 46, 0 +tagerror = 46, 200, 0 +replacetag = 225, 225, 225 +modifier = 225, 225, 225 diff --git a/nw/assets/themes/syntax/grey_light.conf b/nw/assets/themes/syntax/grey_light.conf new file mode 100644 index 00000000..a44593b4 --- /dev/null +++ b/nw/assets/themes/syntax/grey_light.conf @@ -0,0 +1,25 @@ +[Main] +name = Grey Light +author = Veronica Berglyd Olsen +credit = Veronica Berglyd Olsen +url = https://github.com/vkbo/novelWriter +license = CC BY-SA 4.0 +licenseurl = https://creativecommons.org/licenses/by-sa/4.0/ + +[Syntax] +background = 255, 255, 255 +text = 20, 20, 20 +link = 20, 20, 20 +headertext = 0, 0, 0 +headertag = 0, 0, 0 +emphasis = 20, 20, 20 +straightquotes = 20, 20, 20 +doublequotes = 20, 20, 20 +singlequotes = 20, 20, 20 +hidden = 100, 100, 100 +keyword = 0, 0, 0 +value = 20, 20, 20 +spellcheckline = 200, 0, 0 +tagerror = 0, 150, 0 +replacetag = 0, 0, 0 +modifier = 0, 0, 0 diff --git a/nw/config.py b/nw/config.py index 05345b45..9c232109 100644 --- a/nw/config.py +++ b/nw/config.py @@ -27,12 +27,12 @@ import logging import configparser +import shutil import json import sys import os from time import time -from shutil import which from PyQt5.Qt import PYQT_VERSION_STR from PyQt5.QtCore import QT_VERSION_STR, QStandardPaths, QSysInfo @@ -44,10 +44,11 @@ logger = logging.getLogger(__name__) class Config: - CNF_STR = 0 - CNF_INT = 1 - CNF_BOOL = 2 - CNF_LIST = 3 + CNF_STR = 0 + CNF_INT = 1 + CNF_BOOL = 2 + CNF_S_LST = 3 + CNF_I_LST = 4 def __init__(self): @@ -91,6 +92,7 @@ class Config: self.guiFont = "" # Defaults to system default font self.guiFontSize = 11 self.guiScale = 1.0 # Set automatically by Theme class + self.lastNotes = "" # The latest release notes that have been shown ## Sizes self.winGeometry = [1200, 650] @@ -210,12 +212,8 @@ class Config: self.osUnknown = True # Other System Info - if self.verQtValue >= 50600: - self.hostName = QSysInfo.machineHostName() - self.kernelVer = QSysInfo.kernelVersion() - else: - self.hostName = "Unknown" - self.kernelVer = "Unknown" + self.hostName = "Unknown" + self.kernelVer = "Unknown" # Packages self.hasEnchant = False # The pyenchant package @@ -320,6 +318,11 @@ class Config: self.errData.append(str(e)) self.dataPath = None + # Host and Kernel + if self.verQtValue >= 50600: + self.hostName = QSysInfo.machineHostName() + self.kernelVer = QSysInfo.kernelVersion() + # Load recent projects cache self.loadRecentCache() @@ -380,29 +383,32 @@ class Config: self.guiFontSize = self._parseLine( cnfParse, cnfSec, "guifontsize", self.CNF_INT, self.guiFontSize ) + self.lastNotes = self._parseLine( + cnfParse, cnfSec, "lastnotes", self.CNF_STR, self.lastNotes + ) ## Sizes cnfSec = "Sizes" self.winGeometry = self._parseLine( - cnfParse, cnfSec, "geometry", self.CNF_LIST, self.winGeometry + cnfParse, cnfSec, "geometry", self.CNF_I_LST, self.winGeometry ) self.treeColWidth = self._parseLine( - cnfParse, cnfSec, "treecols", self.CNF_LIST, self.treeColWidth + cnfParse, cnfSec, "treecols", self.CNF_I_LST, self.treeColWidth ) self.projColWidth = self._parseLine( - cnfParse, cnfSec, "projcols", self.CNF_LIST, self.projColWidth + cnfParse, cnfSec, "projcols", self.CNF_I_LST, self.projColWidth ) self.mainPanePos = self._parseLine( - cnfParse, cnfSec, "mainpane", self.CNF_LIST, self.mainPanePos + cnfParse, cnfSec, "mainpane", self.CNF_I_LST, self.mainPanePos ) self.docPanePos = self._parseLine( - cnfParse, cnfSec, "docpane", self.CNF_LIST, self.docPanePos + cnfParse, cnfSec, "docpane", self.CNF_I_LST, self.docPanePos ) self.viewPanePos = self._parseLine( - cnfParse, cnfSec, "viewpane", self.CNF_LIST, self.viewPanePos + cnfParse, cnfSec, "viewpane", self.CNF_I_LST, self.viewPanePos ) self.outlnPanePos = self._parseLine( - cnfParse, cnfSec, "outlinepane", self.CNF_LIST, self.outlnPanePos + cnfParse, cnfSec, "outlinepane", self.CNF_I_LST, self.outlnPanePos ) self.isFullScreen = self._parseLine( cnfParse, cnfSec, "fullscreen", self.CNF_BOOL, self.isFullScreen @@ -480,10 +486,10 @@ class Config: cnfParse, cnfSec, "autoscrollpos", self.CNF_INT, self.autoScrollPos ) self.fmtSingleQuotes = self._parseLine( - cnfParse, cnfSec, "fmtsinglequote", self.CNF_LIST, self.fmtSingleQuotes + cnfParse, cnfSec, "fmtsinglequote", self.CNF_S_LST, self.fmtSingleQuotes ) self.fmtDoubleQuotes = self._parseLine( - cnfParse, cnfSec, "fmtdoublequote", self.CNF_LIST, self.fmtDoubleQuotes + cnfParse, cnfSec, "fmtdoublequote", self.CNF_S_LST, self.fmtDoubleQuotes ) self.spellTool = self._parseLine( cnfParse, cnfSec, "spelltool", self.CNF_STR, self.spellTool @@ -584,6 +590,7 @@ class Config: cnfParse.set(cnfSec, "guidark", str(self.guiDark)) cnfParse.set(cnfSec, "guifont", str(self.guiFont)) cnfParse.set(cnfSec, "guifontsize", str(self.guiFontSize)) + cnfParse.set(cnfSec, "lastnotes", str(self.lastNotes)) ## Sizes cnfSec = "Sizes" @@ -896,23 +903,28 @@ class Config: # Internal Functions ## - def _unpackList(self, inStr, listLen, listDefault, castTo=int): - """Unpack a comma separated string of items into a list. - """ - inData = inStr.split(",") - outData = [] - for i in range(listLen): - try: - outData.append(castTo(inData[i])) - except Exception: - outData.append(listDefault[i]) - return outData - def _packList(self, inData): - """Pack a list of items into a comma separated string. + """Pack a list of items into a comma-separated string. """ return ", ".join([str(inVal) for inVal in inData]) + def _unpackList(self, inStr, listDefault, cnfType): + """Unpack a comma-separated string of items into a list. + """ + inData = inStr.split(",") + outData = listDefault.copy() + for i in range(min(len(inData), len(listDefault))): + try: + if cnfType == self.CNF_S_LST: + outData[i] = inData[i].strip() + elif cnfType == self.CNF_I_LST: + outData[i] = int(inData[i].strip()) + else: + continue + except Exception: + continue + return outData + def _parseLine(self, cnfParse, cnfSec, cnfName, cnfType, cnfDefault): """Parse a line and return the correct datatype. """ @@ -925,18 +937,24 @@ class Config: return cnfParse.getint(cnfSec, cnfName) elif cnfType == self.CNF_BOOL: return cnfParse.getboolean(cnfSec, cnfName) - elif cnfType == self.CNF_LIST: + elif cnfType == self.CNF_I_LST: return self._unpackList( - cnfParse.get(cnfSec, cnfName), len(cnfDefault), cnfDefault + cnfParse.get(cnfSec, cnfName), cnfDefault, self.CNF_I_LST + ) + elif cnfType == self.CNF_S_LST: + return self._unpackList( + cnfParse.get(cnfSec, cnfName), cnfDefault, self.CNF_S_LST ) except ValueError as e: logger.error("Failed to load value from config file.") logger.error(str(e)) + return cnfDefault return cnfDefault def _checkNone(self, checkVal): - """Convert a string to a none type. + """Return a NoneType if the value correspomds to None, otherwise + return the value unchanged. """ if checkVal is None: return None @@ -956,10 +974,8 @@ class Config: self.hasEnchant = False logger.debug("Checking package 'pyenchant': Missing") - try: - self.hasAssistant = which("assistant") - except Exception: - self.hasAssistant = False + assistPath = shutil.which("assistant") + self.hasAssistant = assistPath is not None if self.hasAssistant: logger.debug("Checking executable 'assistant': Ok") else: diff --git a/nw/core/document.py b/nw/core/document.py index 2597eac8..7ae8f4de 100644 --- a/nw/core/document.py +++ b/nw/core/document.py @@ -168,7 +168,8 @@ class NWDoc(): os.unlink(docPath) os.rename(docTemp, docPath) - self.theParent.setStatus("Saved Document: %s" % self._theItem.itemName) + if self._theItem is not None: + self.theParent.setStatus("Saved Document: %s" % self._theItem.itemName) return True diff --git a/nw/core/item.py b/nw/core/item.py index 6793af39..477f2df9 100644 --- a/nw/core/item.py +++ b/nw/core/item.py @@ -43,7 +43,7 @@ class NWItem(): self.itemName = "" self.itemHandle = None self.itemParent = None - self.itemOrder = None + self.itemOrder = 0 self.itemType = nwItemType.NO_TYPE self.itemClass = nwItemClass.NO_CLASS self.itemLayout = nwItemLayout.NO_LAYOUT @@ -96,20 +96,21 @@ class NWItem(): return False if "handle" in xItem.attrib: - self.itemHandle = xItem.attrib["handle"] + self.setHandle(xItem.attrib["handle"]) else: logger.error("XML item entry does not have a handle") return False if "parent" in xItem.attrib: - self.itemParent = xItem.attrib["parent"] + self.setParent(xItem.attrib["parent"]) + + if "order" in xItem.attrib: + self.setOrder(xItem.attrib["order"]) retStatus = True for xValue in xItem: if xValue.tag == "name": self.setName(xValue.text) - elif xValue.tag == "order": - self.setOrder(xValue.text) elif xValue.tag == "type": self.setType(xValue.text) elif xValue.tag == "class": @@ -156,7 +157,10 @@ class NWItem(): def setName(self, theName): """Set the item name. """ - self.itemName = theName.strip() + if isinstance(theName, str): + self.itemName = theName.strip() + else: + self.itemName = "" return def setHandle(self, theHandle): diff --git a/nw/core/options.py b/nw/core/options.py index 35358fc0..e0a937dc 100644 --- a/nw/core/options.py +++ b/nw/core/options.py @@ -176,11 +176,7 @@ class OptionState(): """ if getGroup in self.theState: if getName in self.theState[getGroup]: - try: - return self.theState[getGroup][getName] - except Exception as e: - logger.warning(str(e)) - return defaultValue + return self.theState[getGroup][getName] return defaultValue def getString(self, getGroup, getName, defaultValue): @@ -189,11 +185,7 @@ class OptionState(): """ if getGroup in self.theState: if getName in self.theState[getGroup]: - try: - return str(self.theState[getGroup][getName]) - except Exception as e: - logger.warning(str(e)) - return defaultValue + return str(self.theState[getGroup][getName]) return defaultValue def getInt(self, getGroup, getName, defaultValue): @@ -228,11 +220,7 @@ class OptionState(): """ if getGroup in self.theState: if getName in self.theState[getGroup]: - try: - return bool(self.theState[getGroup][getName]) - except Exception as e: - logger.warning(str(e)) - return defaultValue + return bool(self.theState[getGroup][getName]) return defaultValue ## diff --git a/nw/core/project.py b/nw/core/project.py index f8e45194..1a834d73 100644 --- a/nw/core/project.py +++ b/nw/core/project.py @@ -28,12 +28,10 @@ import nw import logging import os +import shutil from lxml import etree from time import time -from shutil import make_archive, unpack_archive, copyfile - -from PyQt5.QtWidgets import QMessageBox from nw.core.tree import NWTree from nw.core.item import NWItem @@ -436,26 +434,15 @@ class NWProject(): xRoot = nwXML.getroot() nwxRoot = xRoot.tag - appVersion = "Unknown" - hexVersion = "0x0" - fileVersion = "Unknown" - self.saveCount = 0 - self.autoCount = 0 - - if "appVersion" in xRoot.attrib: - appVersion = xRoot.attrib["appVersion"] - if "hexVersion" in xRoot.attrib: - hexVersion = xRoot.attrib["hexVersion"] - if "fileVersion" in xRoot.attrib: - fileVersion = xRoot.attrib["fileVersion"] + appVersion = xRoot.attrib.get("appVersion", "Unknown") + hexVersion = xRoot.attrib.get("hexVersion", "0x0") + fileVersion = xRoot.attrib.get("fileVersion", "Unknown") # The following are deprecated and will be removed - if "saveCount" in xRoot.attrib: - self.saveCount = checkInt(xRoot.attrib["saveCount"], 0, False) - if "autoCount" in xRoot.attrib: - self.autoCount = checkInt(xRoot.attrib["autoCount"], 0, False) - if "editTime" in xRoot.attrib: - self.editTime = checkInt(xRoot.attrib["editTime"], 0, False) + # The settings have been moved to the tag + self.saveCount = checkInt(xRoot.attrib.get("saveCount", 0), 0, False) + self.autoCount = checkInt(xRoot.attrib.get("autoCount", 0), 0, False) + self.editTime = checkInt(xRoot.attrib.get("editTime", 0), 0, False) logger.verbose("XML root is %s" % nwxRoot) logger.verbose("File version is %s" % fileVersion) @@ -483,20 +470,19 @@ class NWProject(): # parser will lose the autoReplace settings if allowed to # read the file. Introduced in version 0.10. - if fileVersion == "1.0" and self.mainConf.showGUI: - msgBox = QMessageBox() - msgRes = msgBox.question(self.theParent, "Old Project Version", ( + if fileVersion == "1.0": + msgRes = self.theParent.askQuestion("Old Project Version", ( "The project file and data is created by a novelWriter version " "lower than 0.7. Do you want to upgrade the project to the " "most recent format?

Note that after the upgrade, you " "cannot open the project with an older version of novelWriter " "any more, so make sure you have a recent backup." )) - if msgRes != QMessageBox.Yes: + if not msgRes: self.clearProject() return False - elif fileVersion != "1.1" and fileVersion != "1.2" and self.mainConf.showGUI: + elif fileVersion != "1.1" and fileVersion != "1.2": self.makeAlert(( "Unknown or unsupported novelWriter project file format. " "The project cannot be opened by this version of novelWriter. " @@ -510,9 +496,8 @@ class NWProject(): # Check novelWriter Version # ========================= - if int(hexVersion, 16) > int(nw.__hexversion__, 16) and self.mainConf.showGUI: - msgBox = QMessageBox() - msgRes = msgBox.question(self.theParent, "Version Conflict", ( + if int(hexVersion, 16) > int(nw.__hexversion__, 16): + msgRes = self.theParent.askQuestion("Version Conflict", ( "This project was saved by a newer version of novelWriter, version %s. " "This is version %s. If you continue to open the project, some attributes " "and settings may not be preserved, but the overall project should be fine. " @@ -520,7 +505,7 @@ class NWProject(): ) % ( appVersion, nw.__version__ )) - if msgRes != QMessageBox.Yes: + if not msgRes: self.clearProject() return False @@ -835,15 +820,15 @@ class NWProject(): try: self._clearLockFile() - make_archive(baseName, "zip", self.projPath, ".") + shutil.make_archive(baseName, "zip", self.projPath, ".") self._writeLockFile() + logger.info("Backup written to: %s" % archName) if doNotify: self.theParent.makeAlert( "Backup archive file written to: %s.zip" % os.path.join(cleanName, archName), nwAlert.INFO ) - else: - logger.info("Backup written to: %s" % archName) + except Exception as e: self.theParent.makeAlert( ["Could not write backup archive.", str(e)], @@ -874,7 +859,7 @@ class NWProject(): self.setProjectPath(projPath, newProject=True) try: - unpack_archive(pkgSample, projPath) + shutil.unpack_archive(pkgSample, projPath) isSuccess = True except Exception as e: self.makeAlert( @@ -887,14 +872,14 @@ class NWProject(): try: srcProj = os.path.join(srcSample, nwFiles.PROJ_FILE) dstProj = os.path.join(projPath, nwFiles.PROJ_FILE) - copyfile(srcProj, dstProj) + shutil.copyfile(srcProj, dstProj) srcContent = os.path.join(srcSample, "content") dstContent = os.path.join(projPath, "content") for srcFile in os.listdir(srcContent): srcDoc = os.path.join(srcContent, srcFile) dstDoc = os.path.join(dstContent, srcFile) - copyfile(srcDoc, dstDoc) + shutil.copyfile(srcDoc, dstDoc) isSuccess = True @@ -998,11 +983,15 @@ class NWProject(): "You must set a valid backup path in preferences to use " "the automatic project backup feature." ), nwAlert.WARN) + return False + if self.projName == "": self.theParent.makeAlert(( "You must set a valid project name in project settings to " "use the automatic project backup feature." ), nwAlert.WARN) + return False + return True def setSpellCheck(self, theMode): @@ -1011,12 +1000,15 @@ class NWProject(): if self.spellCheck != theMode: self.spellCheck = theMode self.setProjectChanged(True) - return True + return self.spellCheck def setSpellLang(self, theLang): """Set the project-specific spell check language. """ - self.projLang = checkString(theLang, None, True) + theLang = checkString(theLang, None, True) + if self.projLang != theLang: + self.projLang = theLang + self.setProjectChanged(True) return True def setAutoOutline(self, theMode): @@ -1025,7 +1017,7 @@ class NWProject(): if self.autoOutline != theMode: self.autoOutline = theMode self.setProjectChanged(True) - return True + return self.autoOutline def setTreeOrder(self, newOrder): """A list representing the linear/flattened order of project @@ -1072,7 +1064,7 @@ class NWProject(): if nwItem.itemStatus in replaceMap.keys(): nwItem.setStatus(replaceMap[nwItem.itemStatus]) self.setProjectChanged(True) - return + return True def setImportColours(self, newCols): """Update the list of note file importance flags. Also iterate @@ -1084,14 +1076,15 @@ class NWProject(): if nwItem.itemStatus in replaceMap.keys(): nwItem.setStatus(replaceMap[nwItem.itemStatus]) self.setProjectChanged(True) - return + return True def setAutoReplace(self, autoReplace): """Update the auto-replace dictionary. This replaces the entire dictionary, so alterations have to be made in a copy. """ self.autoReplace = autoReplace - return + self.setProjectChanged(True) + return True def setTitleFormat(self, titleFormat): """Set the formatting of titles in the project. @@ -1099,7 +1092,7 @@ class NWProject(): for valKey, valEntry in titleFormat.items(): if valKey in self.titleFormat: self.titleFormat[valKey] = checkString(valEntry, self.titleFormat[valKey], False) - return + return True def setProjectChanged(self, bValue): """Toggle the project changed flag, and propagate the @@ -1132,13 +1125,11 @@ class NWProject(): sentItems = [] iterItems = self.projTree.handles() n = 0 - nMax = len(iterItems) + nMax = min(len(iterItems), 10000) while n < nMax: tHandle = iterItems[n] tItem = self.projTree[tHandle] n += 1 - if n > 10000: - return # Just in case if tItem is None: # Technically a bug since treeOrder is built from the # same data as projTree @@ -1154,10 +1145,11 @@ class NWProject(): yield tItem elif tItem.itemParent in iterItems: # Item's parent exists, but hasn't been sent yet, so add - # it again to the end + # it again to the end, but make sure this doesn't get + # out hand, so we cap at 10000 items logger.warning("Item %s found before its parent" % tHandle) iterItems.append(tHandle) - nMax = len(iterItems) + nMax = min(len(iterItems), 10000) else: # Item is orphaned logger.error("Item %s has no parent in current tree" % tHandle) @@ -1196,13 +1188,12 @@ class NWProject(): if not os.path.isfile(lockFile): return [] + theLines = [] try: with open(lockFile, mode="r", encoding="utf8") as inFile: theData = inFile.read() theLines = theData.splitlines() - if len(theLines) == 4: - return theLines - else: + if len(theLines) != 4: return ["ERROR"] except Exception as e: @@ -1210,7 +1201,7 @@ class NWProject(): logger.error(str(e)) return ["ERROR"] - return ["ERROR"] + return theLines def _writeLockFile(self): """Writes a lock file to the project folder. @@ -1243,13 +1234,12 @@ class NWProject(): if os.path.isfile(lockFile): try: os.unlink(lockFile) - return True except Exception as e: logger.error("Failed to remove project lockfile") logger.error(str(e)) return False - return None + return True def _checkFolder(self, thePath): """Check if a folder exists, and if it doesn't, create it. @@ -1292,7 +1282,7 @@ class NWProject(): back into the project tree. """ if self.projPath is None: - return + return False # Then check the files in the data folder logger.debug("Checking files in project content folder") @@ -1352,7 +1342,7 @@ class NWProject(): orphItem.setLayout(oLayout) self.projTree.append(oHandle, None, orphItem) - return + return True def _appendSessionStats(self): """Append session statistics to the sessions log file. @@ -1363,21 +1353,27 @@ class NWProject(): sessionFile = os.path.join(self.projMeta, nwFiles.SESS_STATS) isFile = os.path.isfile(sessionFile) - with open(sessionFile, mode="a+", encoding="utf8") as outFile: - if not isFile: - # It's a new file, so add a header - if self.lastWCount > 0: - outFile.write("# Offset %d\n" % self.lastWCount) - outFile.write("# %-17s %-19s %8s %8s\n" % ( - "Start Time", "End Time", "Novel", "Notes" + try: + with open(sessionFile, mode="a+", encoding="utf8") as outFile: + if not isFile: + # It's a new file, so add a header + if self.lastWCount > 0: + outFile.write("# Offset %d\n" % self.lastWCount) + outFile.write("# %-17s %-19s %8s %8s\n" % ( + "Start Time", "End Time", "Novel", "Notes" + )) + + outFile.write("%-19s %-19s %8d %8d\n" % ( + formatTimeStamp(self.projOpened), + formatTimeStamp(time()), + self.novelWCount, + self.notesWCount, )) - outFile.write("%-19s %-19s %8d %8d\n" % ( - formatTimeStamp(self.projOpened), - formatTimeStamp(time()), - self.novelWCount, - self.notesWCount, - )) + except Exception as e: + logger.error("Failed to write session stats file") + logger.error(str(e)) + return False return True @@ -1490,7 +1486,8 @@ class NWProject(): os.unlink(rmFile) except Exception as e: logger.error(str(e)) + return False - return + return True # END Class NWProject diff --git a/nw/core/spellcheck.py b/nw/core/spellcheck.py index b2f92a96..83ff2540 100644 --- a/nw/core/spellcheck.py +++ b/nw/core/spellcheck.py @@ -28,8 +28,7 @@ import nw import logging import os - -from difflib import get_close_matches +import difflib from nw.constants import nwConst, isoLanguage @@ -70,14 +69,16 @@ class NWSpellCheck(): """ if self.projectDict is not None and newWord not in self.projDict: newWord = newWord.strip() - self.projDict.append(newWord) try: with open(self.projectDict, mode="a+", encoding="utf-8") as outFile: outFile.write("%s\n" % newWord) + self.projDict.append(newWord) except Exception as e: logger.error("Failed to add word to project word list %s" % str(self.projectDict)) logger.error(str(e)) - return + return False + return True + return False def listDictionaries(self): """Dummy function. @@ -109,9 +110,12 @@ class NWSpellCheck(): """ self.projDict = [] if projectDict is not None: - self.projectDict = projectDict if not os.path.isfile(projectDict): - return + self.projectDict = None + return False + else: + self.projectDict = projectDict + try: logger.debug("Loading project word list") with open(projectDict, mode="r", encoding="utf-8") as wordsFile: @@ -123,7 +127,9 @@ class NWSpellCheck(): except Exception as e: logger.error("Failed to load project word list") logger.error(str(e)) - return + return False + + return True # END Class NWSpellCheck @@ -287,7 +293,7 @@ class NWSpellSimple(NWSpellCheck): if len(theWord) == 0: return [] - theMatches = get_close_matches(theWord.lower(), self.WORDS, n=10, cutoff=0.75) + theMatches = difflib.get_close_matches(theWord.lower(), self.WORDS, n=10, cutoff=0.75) theOptions = [] for aWord in theMatches: if len(aWord) == 0: @@ -314,14 +320,12 @@ class NWSpellSimple(NWSpellCheck): retList = [] for dictFile in os.listdir(self.mainConf.dictPath): - theBits = os.path.splitext(dictFile) - if len(theBits) != 2: - continue - if theBits[1] != ".dict": + fRoot, fExt = os.path.splitext(dictFile) + if fExt != ".dict": continue - spName = "%s [%s]" % (self.expandLanguage(theBits[0]), nwConst.SP_INTERNAL) - retList.append((theBits[0], spName)) + spName = "%s [%s]" % (self.expandLanguage(fRoot), nwConst.SP_INTERNAL) + retList.append((fRoot, spName)) return retList diff --git a/nw/core/status.py b/nw/core/status.py index 7a1c7127..ccec2e73 100644 --- a/nw/core/status.py +++ b/nw/core/status.py @@ -79,6 +79,7 @@ class NWStatus(): theStatus = checkInt(theStatus, 0, False) if theStatus >= 0 and theStatus < self._theLength: return self._theLabels[theStatus] + return self._theLabels[0] def setNewEntries(self, newList): """Update the list of entries after they have been modified by @@ -108,7 +109,8 @@ class NWStatus(): return def countEntry(self, theLabel): - """Lookup the usage count of a given entry. + """Increment the counter for a given label. This should be used + together with resetCounts in a loop over project items. """ theIndex = self.lookupEntry(theLabel) if theIndex is not None: @@ -136,18 +138,9 @@ class NWStatus(): for xChild in xParent: theLabels.append(xChild.text) - if "red" in xChild.attrib: - cR = checkInt(xChild.attrib["red"], 0, False) - else: - cR = 0 - if "green" in xChild.attrib: - cG = checkInt(xChild.attrib["green"], 0, False) - else: - cG = 0 - if "blue" in xChild.attrib: - cB = checkInt(xChild.attrib["blue"], 0, False) - else: - cB = 0 + cR = checkInt(xChild.attrib.get("red", 0), 0, False) + cG = checkInt(xChild.attrib.get("green", 0), 0, False) + cB = checkInt(xChild.attrib.get("blue", 0), 0, False) theColours.append((cR, cG, cB)) if len(theLabels) > 0: diff --git a/nw/core/tohtml.py b/nw/core/tohtml.py index df6c4a09..f3166d02 100644 --- a/nw/core/tohtml.py +++ b/nw/core/tohtml.py @@ -68,16 +68,16 @@ class ToHtml(Tokenizer): # Setters ## - def setPreview(self, forPreview, doComments, doSynopsis): + def setPreview(self, doComments, doSynopsis): """If we're using this class to generate markdown preview, we need to make a few changes to formatting, which is managed by these flags. """ - if forPreview: - self.genMode = self.M_PREVIEW - self.doKeywords = True - self.doComments = doComments - self.doSynopsis = doSynopsis + self.genMode = self.M_PREVIEW + self.doKeywords = True + self.doComments = doComments + self.doSynopsis = doSynopsis + return def setStyles(self, cssStyles): @@ -141,11 +141,13 @@ class ToHtml(Tokenizer): # For novel files for export, we bump the titles one level # up as this is more useful for printing and word processor # imports. - h1 = "h1 class='title'" + h1Cl = " class='title'" + h1 = "h1" h2 = "h1" h3 = "h2" h4 = "h3" else: + h1Cl = "" h1 = "h1" h2 = "h2" h3 = "h3" @@ -193,7 +195,7 @@ class ToHtml(Tokenizer): else: aNm = "" - # Process TextType + # Process Text Type if tType == self.T_EMPTY: if parStyle is None: parStyle = "" @@ -214,7 +216,7 @@ class ToHtml(Tokenizer): elif tType == self.T_HEAD1: tHead = tText.replace(r"\\", "
") - tmpResult.append("<%s%s>%s%s\n" % (h1, hStyle, aNm, tHead, h1)) + tmpResult.append("<%s%s%s>%s%s\n" % (h1, h1Cl, hStyle, aNm, tHead, h1)) elif tType == self.T_HEAD2: tHead = tText.replace(r"\\", "
") diff --git a/nw/core/tokenizer.py b/nw/core/tokenizer.py index 7870493c..6039121d 100644 --- a/nw/core/tokenizer.py +++ b/nw/core/tokenizer.py @@ -180,7 +180,7 @@ class Tokenizer(): ## def addRootHeading(self, theHandle): - """Add a heading at the start if a new root folder. + """Add a heading at the start of a new root folder. """ theItem = self.theProject.projTree[theHandle] if theItem is None: @@ -205,7 +205,7 @@ class Tokenizer(): self.theHandle = theHandle self.theItem = self.theProject.projTree[theHandle] if self.theItem is None: - return + return False if theText is not None: # If the text is set, just use that @@ -234,7 +234,7 @@ class Tokenizer(): self.isNote = self.theItem.itemLayout == nwItemLayout.NOTE self.isNovel = self.isBook or self.isUnNum or self.isChap or self.isScene - return + return True def getResult(self): """Return the result from the conversion. @@ -244,6 +244,8 @@ class Tokenizer(): def getResultSize(self): """Return the size of the result from the conversion. """ + if self.theResult is None: + return 0 return len(self.theResult) def getFilteredMarkdown(self): @@ -445,7 +447,7 @@ class Tokenizer(): """ # No special header formatting for notes and no-layout files if self.isNone or self.isNote: - return + return False # For novel files, we need to handle chapter numbering, scene # numbering, and scene breaks @@ -479,8 +481,7 @@ class Tokenizer(): if self.isUnNum: tTemp = self._formatHeading(self.fmtUnNum, tToken[2]) elif tToken[2].startswith("*"): - tTemp = self._formatHeading(self.fmtUnNum, tToken[2]) - tTemp = tTemp[1:].lstrip() + tTemp = self._formatHeading(self.fmtUnNum, tToken[2][1:].lstrip()) else: self.numChapter += 1 tTemp = self._formatHeading(self.fmtChapter, tToken[2]) @@ -663,7 +664,7 @@ class Tokenizer(): self.A_LEFT ) - return + return True ## # Internal Functions diff --git a/nw/core/tools.py b/nw/core/tools.py index ef9fbc6b..342f62bb 100644 --- a/nw/core/tools.py +++ b/nw/core/tools.py @@ -141,6 +141,15 @@ def _numberToWordEN(numVal): tenWord = "" hunWord = "" + if not isinstance(numVal, int): + return "[NaN]" + + if numVal < 0: + return "[Negative]" + + if numVal > 999: + return "[Out of Range]" + if numVal == 0: return "Zero" @@ -166,19 +175,20 @@ def _numberToWordEN(numVal): 5: "Five", 6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", } + retVale = "" hunWord = theHundreds.get(hunVal, "") if tenVal == 10: oneWord = theTeens.get(oneVal, "") - return f"{hunWord} {oneWord}".strip() + retVale = f"{hunWord} {oneWord}".strip() else: oneWord = theOnes.get(oneVal, "") if tenVal == 0: - return f"{hunWord} {oneWord}".strip() + retVale = f"{hunWord} {oneWord}".strip() else: tenWord = theTens.get(tenVal, "") if oneVal == 0: - return f"{hunWord} {tenWord}".strip() + retVale = f"{hunWord} {tenWord}".strip() else: - return f"{hunWord} {tenWord}-{oneWord}".strip() + retVale = f"{hunWord} {tenWord}-{oneWord}".strip() - return "" + return retVale diff --git a/nw/core/tree.py b/nw/core/tree.py index ac3ab45c..43678b73 100644 --- a/nw/core/tree.py +++ b/nw/core/tree.py @@ -48,8 +48,10 @@ class NWTree(): self._treeOrder = [] # The order of the tree items on the tree view self._treeRoots = [] # The root items of the tree self._trashRoot = None # The handle of the trash root folder + self._archRoot = None # The handle of the archive root folder self._theIndex = 0 # The current iterator index self._treeChanged = False # True if tree structure has changed + self._handleSeed = None # Used for generating handles for testing return @@ -83,14 +85,15 @@ class NWTree(): if tHandle is None: tHandle = self._makeHandle() + if tHandle in self._projTree: + logger.warning("Duplicate handle %s detected, skipping" % tHandle) + return False + logger.verbose("Adding item %s with parent %s" % (str(tHandle), str(pHandle))) nwItem.setHandle(tHandle) nwItem.setParent(pHandle) - self._projTree[tHandle] = nwItem - self._treeOrder.append(tHandle) - if nwItem.itemType == nwItemType.ROOT: logger.verbose("Item %s is a root item" % str(tHandle)) self._treeRoots.append(tHandle) @@ -104,10 +107,13 @@ class NWTree(): self._trashRoot = tHandle else: logger.error("Only one trash folder allowed") + return False + self._projTree[tHandle] = nwItem + self._treeOrder.append(tHandle) self._setTreeChanged(True) - return + return True def packXML(self, xParent): """Pack the content of the tree into the provided XML object. In @@ -176,8 +182,9 @@ class NWTree(): except Exception as e: logger.error(str(e)) + return False - return + return True def sumWords(self): """Loops over all entries and adds up the word counts. @@ -244,6 +251,8 @@ class NWTree(): return True for aRoot in self._treeRoots: tItem = self.__getitem__(aRoot) + if tItem is None: + continue if theClass == tItem.itemClass: return False return True @@ -397,7 +406,7 @@ class NWTree(): del self._projTree[tHandle] else: logger.warning("Failed to delete item %s: item not found" % tHandle) - return False + return if tHandle in self._treeRoots: self._treeRoots.remove(tHandle) @@ -408,7 +417,7 @@ class NWTree(): self._setTreeChanged(True) - return True + return def __contains__(self, tHandle): """Checks if a handle exists in the tree. diff --git a/nw/error.py b/nw/error.py index 988b49e6..24a802aa 100644 --- a/nw/error.py +++ b/nw/error.py @@ -135,10 +135,9 @@ class NWErrorMessage(QDialog): # END Class NWErrorMessage -def exceptionHandler(exType, exValue, exTrace, testMode=False): +def exceptionHandler(exType, exValue, exTrace): """Function to catch unhandled global exceptions. """ - import nw import logging from traceback import print_tb from PyQt5.QtWidgets import qApp @@ -160,8 +159,7 @@ def exceptionHandler(exType, exValue, exTrace, testMode=False): errMsg = NWErrorMessage(nwGUI) errMsg.setMessage(exType, exValue, exTrace) - if nw.CONFIG.showGUI: - errMsg.exec_() + errMsg.exec_() try: # Try a controlled shudown @@ -173,10 +171,7 @@ def exceptionHandler(exType, exValue, exTrace, testMode=False): logger.critical("Could not close the project before exiting") logger.critical(str(e)) - if testMode: - return errMsg.msgBody.toPlainText() - else: - qApp.exit(1) + qApp.exit(1) except Exception as e: logger.critical(str(e)) diff --git a/nw/gui/about.py b/nw/gui/about.py index 7f00cf11..c0c7885e 100644 --- a/nw/gui/about.py +++ b/nw/gui/about.py @@ -32,8 +32,9 @@ import os from datetime import datetime from PyQt5.QtCore import Qt +from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import ( - QDialog, QHBoxLayout, QVBoxLayout, QDialogButtonBox, QTabWidget, + qApp, QDialog, QHBoxLayout, QVBoxLayout, QDialogButtonBox, QTabWidget, QTextBrowser, QLabel ) @@ -80,6 +81,10 @@ class GuiAbout(QDialog): self.pageAbout.setOpenExternalLinks(True) self.pageAbout.document().setDocumentMargin(self.mainConf.pxInt(16)) + self.pageNotes = QTextBrowser() + self.pageNotes.setOpenExternalLinks(True) + self.pageNotes.document().setDocumentMargin(self.mainConf.pxInt(16)) + self.pageLicense = QTextBrowser() self.pageLicense.setOpenExternalLinks(True) self.pageLicense.document().setDocumentMargin(self.mainConf.pxInt(16)) @@ -87,6 +92,7 @@ class GuiAbout(QDialog): # Main Tab Area self.tabBox = QTabWidget() self.tabBox.addTab(self.pageAbout, "About") + self.tabBox.addTab(self.pageNotes, "Release") self.tabBox.addTab(self.pageLicense, "License") self.innerBox.addWidget(self.tabBox) @@ -98,14 +104,27 @@ class GuiAbout(QDialog): self.outerBox.addWidget(self.buttonBox) self.setLayout(self.outerBox) - self._setStyleSheet() - self._fillAboutPage() - self._fillLicensePage() - logger.debug("GuiAbout initialisation complete") return + def populateGUI(self): + """Populate tabs with text. + """ + qApp.setOverrideCursor(QCursor(Qt.WaitCursor)) + self._setStyleSheet() + self._fillAboutPage() + self._fillNotesPage() + self._fillLicensePage() + qApp.restoreOverrideCursor() + return + + def showReleaseNotes(self): + """Show the release notes. + """ + self.tabBox.setCurrentWidget(self.pageNotes) + return + ## # Internal Functions ## @@ -121,14 +140,14 @@ class GuiAbout(QDialog): "

novelWriter is a markdown-like text editor designed for " "organising and writing novels. It is written in Python 3 with a " "Qt5 GUI, using PyQt5.

" - "

novelWriter is free software: you can redistribute it and/or " + "

novelWriter is free software: you can redistribute it and " "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.

" "

novelWriter 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 License tab for the full text, or visit the GNU website " + "

See the License tab for the full license text, or visit the GNU website " "at GPL v3.0 " "for more details.

" "

Credits

" @@ -192,11 +211,22 @@ class GuiAbout(QDialog): return + def _fillNotesPage(self): + """Load the content for the Release Notes page. + """ + docPath = os.path.join(self.mainConf.assetPath, "text", "release_notes.htm") + if os.path.isfile(docPath): + with open(docPath, mode="r", encoding="utf8") as inFile: + helpText = inFile.read() + self.pageNotes.setHtml(helpText) + else: + self.pageNotes.setHtml("Error loading release notes text ...") + return + def _fillLicensePage(self): """Load the content for the License page. """ - docName = "gplv3_%s.htm" % self.mainConf.guiLang - docPath = os.path.join(self.mainConf.assetPath, "text", docName) + docPath = os.path.join(self.mainConf.assetPath, "text", "gplv3_en.htm") if os.path.isfile(docPath): with open(docPath, mode="r", encoding="utf8") as inFile: helpText = inFile.read() @@ -221,6 +251,7 @@ class GuiAbout(QDialog): hColB = self.theParent.theTheme.colHead[2], ) self.pageAbout.document().setDefaultStyleSheet(styleSheet) + self.pageNotes.document().setDefaultStyleSheet(styleSheet) self.pageLicense.document().setDefaultStyleSheet(styleSheet) return diff --git a/nw/gui/doceditor.py b/nw/gui/doceditor.py index d76623ce..48e44fbf 100644 --- a/nw/gui/doceditor.py +++ b/nw/gui/doceditor.py @@ -262,10 +262,10 @@ class GuiDocEditor(QTextEdit): # If we have a document open, we should reload it in case the # font changed, otherwise we just clear the editor entirely, # which makes it read only. - if self.theHandle is not None: - self.redrawText() - else: + if self.theHandle is None: self.clearEditor() + else: + self.redrawText() return True @@ -355,6 +355,7 @@ class GuiDocEditor(QTextEdit): """Redraw the text by marking the document content as "dirty". """ self.qDocument.markContentsDirty(0, self.qDocument.characterCount()) + self.updateDocMargins() return def replaceText(self, theText): diff --git a/nw/gui/docviewer.py b/nw/gui/docviewer.py index e74e7ada..fab3b5ef 100644 --- a/nw/gui/docviewer.py +++ b/nw/gui/docviewer.py @@ -149,7 +149,7 @@ class GuiDocViewer(QTextBrowser): # If we have a document open, we should reload it in case the font changed if self.theHandle is not None: - self.redrawText() + self.reloadText() return True @@ -169,7 +169,7 @@ class GuiDocViewer(QTextBrowser): sPos = self.verticalScrollBar().value() aDoc = ToHtml(self.theProject, self.theParent) - aDoc.setPreview(True, self.mainConf.viewComments, self.mainConf.viewSynopsis) + aDoc.setPreview(self.mainConf.viewComments, self.mainConf.viewSynopsis) aDoc.setLinkHeaders(True) # Be extra careful here to prevent crashes when first opening a @@ -233,6 +233,7 @@ class GuiDocViewer(QTextBrowser): """Redraw the text by marking the document content as "dirty". """ self.qDocument.markContentsDirty(0, self.qDocument.characterCount()) + self.updateDocMargins() return def loadFromTag(self, theTag): diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index c64debe6..861126c2 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -598,8 +598,8 @@ class GuiMainMenu(QMenuBar): # Insert > Separator self.insertMenu.addSeparator() - # Insert > Keywords and Tags - self.mInsKeywords = self.insertMenu.addMenu("Keywords and Tags") + # Insert > Tags and References + self.mInsKeywords = self.insertMenu.addMenu("Tags and References") self.mInsKWItems = {} self.mInsKWItems[nwKeyWords.TAG_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, G") self.mInsKWItems[nwKeyWords.POV_KEY] = (QAction(self.mInsKeywords), "Ctrl+K, V") @@ -908,7 +908,7 @@ class GuiMainMenu(QMenuBar): self.helpMenu.addAction(self.aHelpLoc) self.aHelpWeb = QAction("Documentation (Online)", self) - self.aHelpWeb.setStatusTip("View online documentation") + self.aHelpWeb.setStatusTip("View online documentation at %s" % nw.__docurl__) self.aHelpWeb.triggered.connect(lambda: self._openWebsite(nw.__docurl__)) if self.mainConf.hasHelp and self.mainConf.hasAssistant: self.aHelpWeb.setShortcut("Shift+F1") @@ -916,18 +916,33 @@ class GuiMainMenu(QMenuBar): self.aHelpWeb.setShortcuts(["F1", "Shift+F1"]) self.helpMenu.addAction(self.aHelpWeb) - # Document > Go to Website - self.aWebsite = QAction("Open the novelWriter Website", self) - self.aWebsite.setStatusTip("View the main website") - self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__)) - self.helpMenu.addAction(self.aWebsite) + # Help > Separator + self.helpMenu.addSeparator() - # Document > Report Issue - self.aIssue = QAction("Report an Issue", self) - self.aIssue.setStatusTip("Report a bug or issue on GitHub") + # Document > Report an Issue + self.aIssue = QAction("Report an Issue (GitHub)", self) + self.aIssue.setStatusTip("Report a bug or issue on GitHub at %s" % nw.__issuesurl__) self.aIssue.triggered.connect(lambda: self._openWebsite(nw.__issuesurl__)) self.helpMenu.addAction(self.aIssue) + # Document > Ask a Question + self.aQuestion = QAction("Ask a Question (GitHub)", self) + self.aQuestion.setStatusTip("Ask a question on GitHub at %s" % nw.__helpurl__) + self.aQuestion.triggered.connect(lambda: self._openWebsite(nw.__helpurl__)) + self.helpMenu.addAction(self.aQuestion) + + # Document > Latest Release + self.aRelease = QAction("Latest Release (GitHub)", self) + self.aRelease.setStatusTip("Open the Releases page on GitHub at %s" % nw.__releaseurl__) + self.aRelease.triggered.connect(lambda: self._openWebsite(nw.__releaseurl__)) + self.helpMenu.addAction(self.aRelease) + + # Document > Main Website + self.aWebsite = QAction("The novelWriter Website", self) + self.aWebsite.setStatusTip("Open the novelWriter website at %s" % nw.__url__) + self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__)) + self.helpMenu.addAction(self.aWebsite) + return # END Class GuiMainMenu diff --git a/nw/gui/preferences.py b/nw/gui/preferences.py index 2bb22bde..389c9f6f 100644 --- a/nw/gui/preferences.py +++ b/nw/gui/preferences.py @@ -227,7 +227,7 @@ class GuiConfigEditGeneralTab(QWidget): self.mainForm.addRow( "Show full path in document header", self.showFullPath, - "Shows the document title and parent folder names." + "Add the parent folder names to the header." ) self.hideVScroll = QSwitch() @@ -235,7 +235,7 @@ class GuiConfigEditGeneralTab(QWidget): self.mainForm.addRow( "Hide vertical scroll bars in main windows", self.hideVScroll, - "Scrolling with mouse wheel and keys only." + "Scrolling available with mouse wheel and keys only." ) self.hideHScroll = QSwitch() @@ -243,7 +243,7 @@ class GuiConfigEditGeneralTab(QWidget): self.mainForm.addRow( "Hide horizontal scroll bars in main windows", self.hideHScroll, - "Scrolling with mouse wheel and keys only." + "Scrolling available with mouse wheel and keys only." ) return @@ -325,8 +325,9 @@ class GuiConfigEditProjectsTab(QWidget): self.autoSaveDoc.setSingleStep(1) self.autoSaveDoc.setValue(self.mainConf.autoSaveDoc) self.backupPathRow = self.mainForm.addRow( - "Save interval for the currently open document", + "Save document interval", self.autoSaveDoc, + "How often the open document is automatically saved.", theUnit="seconds" ) @@ -337,8 +338,9 @@ class GuiConfigEditProjectsTab(QWidget): self.autoSaveProj.setSingleStep(1) self.autoSaveProj.setValue(self.mainConf.autoSaveProj) self.backupPathRow = self.mainForm.addRow( - "Save interval for the currently open project", + "Save project interval", self.autoSaveProj, + "How often the open project is automatically saved.", theUnit="seconds" ) @@ -361,9 +363,9 @@ class GuiConfigEditProjectsTab(QWidget): self.backupOnClose.setChecked(self.mainConf.backupOnClose) self.backupOnClose.toggled.connect(self._toggledBackupOnClose) self.mainForm.addRow( - "Run backup when closing project", + "Run backup when the project is closed", self.backupOnClose, - "This option can be overridden in project settings." + "Can be overridden for individual projects in project settings." ) ## Ask before backup @@ -373,7 +375,8 @@ class GuiConfigEditProjectsTab(QWidget): self.askBeforeBackup.setEnabled(self.mainConf.backupOnClose) self.mainForm.addRow( "Ask before running backup", - self.askBeforeBackup + self.askBeforeBackup, + "Disabling this will cause backups to run in the background." ) return @@ -426,7 +429,7 @@ class GuiConfigEditProjectsTab(QWidget): def _toggledBackupOnClose(self, theState): """Enable or disable switch that depends on the backup on close - switch, + switch. """ self.askBeforeBackup.setEnabled(theState) return @@ -449,7 +452,7 @@ class GuiConfigEditLayoutTab(QWidget): # Text Style # ========== - self.mainForm.addGroupLabel("Text Style") + self.mainForm.addGroupLabel("Document Text Style") ## Font Family self.textStyleFont = QLineEdit() @@ -475,12 +478,13 @@ class GuiConfigEditLayoutTab(QWidget): self.mainForm.addRow( "Font size", self.textStyleSize, + "Font size for the document editor and viewer.", theUnit = "pt" ) # Text Flow # ========= - self.mainForm.addGroupLabel("Text Flow") + self.mainForm.addGroupLabel("Document Text Flow") ## Max Text Width in Normal Mode self.textFlowMax = QSpinBox(self) @@ -491,6 +495,7 @@ class GuiConfigEditLayoutTab(QWidget): self.mainForm.addRow( "Maximum text width in \"Normal Mode\"", self.textFlowMax, + "Horizontal margins are scaled automatically.", theUnit="px" ) @@ -503,6 +508,7 @@ class GuiConfigEditLayoutTab(QWidget): self.mainForm.addRow( "Maximum text width in \"Focus Mode\"", self.focusDocWidth, + "Horizontal margins are scaled automatically.", theUnit="px" ) @@ -512,7 +518,7 @@ class GuiConfigEditLayoutTab(QWidget): self.mainForm.addRow( "Disable maximum text width in \"Normal Mode\"", self.textFlowFixed, - "If disabled, minimum text width is defined by the margin setting." + "If disabled, minimum text width is defined by the margin." ) ## Focus Mode Footer @@ -520,7 +526,8 @@ class GuiConfigEditLayoutTab(QWidget): self.hideFocusFooter.setChecked(self.mainConf.hideFocusFooter) self.mainForm.addRow( "Hide document footer in \"Focus Mode\"", - self.hideFocusFooter + self.hideFocusFooter, + "Hide the information bar at the bottom of the document." ) ## Justify Text @@ -528,7 +535,8 @@ class GuiConfigEditLayoutTab(QWidget): self.textJustify.setChecked(self.mainConf.textFixedW) self.mainForm.addRow( "Justify the text margins in editor and viewer", - self.textJustify + self.textJustify, + "Lay out text with straight edges in the editor and viewer." ) ## Document Margins @@ -538,9 +546,9 @@ class GuiConfigEditLayoutTab(QWidget): self.textMargin.setSingleStep(1) self.textMargin.setValue(self.mainConf.textMargin) self.mainForm.addRow( - "Document text margin", + "Text margin", self.textMargin, - "If max width is enabled, this is the minimum margin.", + "If maximum width is set, this becomes the minimum margin.", theUnit="px" ) @@ -551,8 +559,9 @@ class GuiConfigEditLayoutTab(QWidget): self.tabWidth.setSingleStep(1) self.tabWidth.setValue(self.mainConf.tabWidth) self.mainForm.addRow( - "Document tab width", + "Tab width", self.tabWidth, + "The width of a tab key press in the editor and viewer.", theUnit="px" ) @@ -566,7 +575,7 @@ class GuiConfigEditLayoutTab(QWidget): self.mainForm.addRow( "Scroll past end of the document", self.scrollPastEnd, - "Allows scrolling until last line is at the top." + "Allow scrolling until the last line is centred in the editor." ) ## Typewriter Scrolling @@ -575,7 +584,7 @@ class GuiConfigEditLayoutTab(QWidget): self.mainForm.addRow( "Typewriter style scrolling when you type", self.autoScroll, - "Tries to keep the cursor at a fixed vertical position." + "Try to keep the cursor at a fixed vertical position." ) ## Font Size @@ -678,7 +687,7 @@ class GuiConfigEditEditingTab(QWidget): self.mainForm.addRow( "Highlight theme", self.selectSyntax, - "" + "Colour theme to apply to the editor and viewer." ) self.highlightQuotes = QSwitch() @@ -686,7 +695,7 @@ class GuiConfigEditEditingTab(QWidget): self.mainForm.addRow( "Highlight text wrapped in quotes", self.highlightQuotes, - helpText="Applies to single, double and straight quotes." + "Applies to single, double and straight quotes." ) self.highlightEmph = QSwitch() @@ -694,7 +703,7 @@ class GuiConfigEditEditingTab(QWidget): self.mainForm.addRow( "Add highlight colour to emphasised text", self.highlightEmph, - helpText="Applies to emphasis, strong and strikethrough." + "Applies to emphasis, strong and strikethrough." ) # Spell Checking @@ -724,7 +733,8 @@ class GuiConfigEditEditingTab(QWidget): ) self.mainForm.addRow( "Spell check language", - self.spellLangList + self.spellLangList, + "Available languages are determined by your system." ) ## Big Document Size Limit @@ -736,7 +746,7 @@ class GuiConfigEditEditingTab(QWidget): self.mainForm.addRow( "Big document limit", self.bigDocLimit, - "Disables full spell checking over the size limit.", + "Full spell checking is disabled above this limit.", theUnit="kB" ) @@ -749,7 +759,8 @@ class GuiConfigEditEditingTab(QWidget): self.showTabsNSpaces.setChecked(self.mainConf.showTabsNSpaces) self.mainForm.addRow( "Show tabs and spaces", - self.showTabsNSpaces + self.showTabsNSpaces, + "Add symbols to indicate tabs and spaces in the editor." ) ## Show Line Endings @@ -757,7 +768,8 @@ class GuiConfigEditEditingTab(QWidget): self.showLineEndings.setChecked(self.mainConf.showLineEndings) self.mainForm.addRow( "Show line endings", - self.showLineEndings + self.showLineEndings, + "Add a symbol to indicate line endings in the editor." ) return @@ -866,7 +878,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Auto-replace text as you type", self.autoReplaceMain, - "Apply formatting to word under cursor if no selection is made." + "Allow the editor to replace symbols as you type." ) # Auto-Replace @@ -880,7 +892,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Auto-replace single quotes", self.autoReplaceSQ, - "The feature will try to guess opening or closing single quote." + "Try to guess which is an opening or a closing single quote." ) ## Auto-Replace Double Quotes @@ -890,7 +902,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Auto-replace double quotes", self.autoReplaceDQ, - "The feature will try to guess opening or closing quote quote." + "Try to guess which is an opening or a closing double quote." ) ## Auto-Replace Hyphens @@ -900,7 +912,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Auto-replace dashes", self.autoReplaceDash, - "Auto-replace double and triple hyphens with short and long dash." + "Double and triple hyphens become short and long dashes." ) ## Auto-Replace Dots @@ -910,7 +922,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Auto-replace dots", self.autoReplaceDots, - "Auto-replace three dots with ellipsis." + "Three consecutive dots becomes ellipsis." ) # Quotation Style @@ -934,7 +946,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Single quote open style", self.quoteSym["SO"], - "Auto-replaces apostrophe before words.", + "The symbol to use for a leading single quote.", theButton=self.btnSingleStyleO ) @@ -950,7 +962,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Single quote close style", self.quoteSym["SC"], - "Auto-replaces apostrophe after words.", + "The symbol to use for a trailing single quote.", theButton=self.btnSingleStyleC ) @@ -967,7 +979,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Double quote open style", self.quoteSym["DO"], - "Auto-replaces straight quotes before words.", + "The symbol to use for a leading double quote.", theButton=self.btnDoubleStyleO ) @@ -983,7 +995,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Double quote close style", self.quoteSym["DC"], - "Auto-replaces straight quotes after words.", + "The symbol to use for a trailing double quote.", theButton=self.btnDoubleStyleC ) diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 4116a863..3e91cc2d 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -286,36 +286,36 @@ class GuiProjectTree(QTreeWidget): logger.error("No project open") return False - hasFocus = qApp.focusWidget() == self or not self.mainConf.showGUI - if hasFocus and self.theParent.hasProject: + if qApp.focusWidget() != self and self.mainConf.showGUI: + return False - tHandle = self.getSelectedHandle() - tItem = self._getTreeItem(tHandle) - pItem = tItem.parent() - if pItem is None: - tIndex = self.indexOfTopLevelItem(tItem) - nChild = self.topLevelItemCount() - nIndex = tIndex + nStep - if nIndex < 0 or nIndex >= nChild: - return False - cItem = self.takeTopLevelItem(tIndex) - self.insertTopLevelItem(nIndex, cItem) + tHandle = self.getSelectedHandle() + tItem = self._getTreeItem(tHandle) + if tItem is None: + return False - else: - tIndex = pItem.indexOfChild(tItem) - nChild = pItem.childCount() - nIndex = tIndex + nStep - if nIndex < 0 or nIndex >= nChild: - return False - cItem = pItem.takeChild(tIndex) - pItem.insertChild(nIndex, cItem) - - self.clearSelection() - cItem.setSelected(True) - self._setTreeChanged(True) + pItem = tItem.parent() + if pItem is None: + tIndex = self.indexOfTopLevelItem(tItem) + nChild = self.topLevelItemCount() + nIndex = tIndex + nStep + if nIndex < 0 or nIndex >= nChild: + return False + cItem = self.takeTopLevelItem(tIndex) + self.insertTopLevelItem(nIndex, cItem) else: - return False + tIndex = pItem.indexOfChild(tItem) + nChild = pItem.childCount() + nIndex = tIndex + nStep + if nIndex < 0 or nIndex >= nChild: + return False + cItem = pItem.takeChild(tIndex) + pItem.insertChild(nIndex, cItem) + + self.clearSelection() + cItem.setSelected(True) + self._setTreeChanged(True) return True @@ -431,7 +431,7 @@ class GuiProjectTree(QTreeWidget): trItemS = self._getTreeItem(tHandle) nwItemS = self.theProject.projTree[tHandle] - if nwItemS is None: + if trItemS is None or nwItemS is None: return False wCount = int(trItemS.data(self.C_COUNT, Qt.UserRole)) @@ -582,18 +582,23 @@ class GuiProjectTree(QTreeWidget): properly reported to the function. """ tItem = self._getTreeItem(tHandle) - if tItem is not None: - tItem.setText(self.C_COUNT, f"{theCount:n}") - tItem.setData(self.C_COUNT, Qt.UserRole, int(theCount)) - pItem = tItem.parent() - if pItem is not None: - pCount = 0 - for i in range(pItem.childCount()): - pCount += int(pItem.child(i).data(self.C_COUNT, Qt.UserRole)) - pHandle = pItem.data(self.C_NAME, Qt.UserRole) + if tItem is None: + return - if not nDepth > nwConst.MAX_DEPTH + 1 and pHandle != "": - self.propagateCount(pHandle, pCount, nDepth+1) + tItem.setText(self.C_COUNT, f"{theCount:n}") + tItem.setData(self.C_COUNT, Qt.UserRole, int(theCount)) + + pItem = tItem.parent() + if pItem is None: + return + + pCount = 0 + for i in range(pItem.childCount()): + pCount += int(pItem.child(i).data(self.C_COUNT, Qt.UserRole)) + pHandle = pItem.data(self.C_NAME, Qt.UserRole) + + if not nDepth > nwConst.MAX_DEPTH + 1 and pHandle != "": + self.propagateCount(pHandle, pCount, nDepth+1) return @@ -1024,12 +1029,12 @@ class GuiProjectTreeMenu(QMenu): return def filterActions(self, theItem): - """Update item settings from the nwItem. + """Filter the menu entries available based on the properties of + the item the menu was activated on. """ self.theItem = theItem - theRoot = self.theTree.theProject.projTree.getRootItem(theItem.itemHandle) - if theItem is None or theRoot is None: + if theItem is None: logger.error("Failed to extract information to build tree context menu") return False @@ -1038,14 +1043,13 @@ class GuiProjectTreeMenu(QMenu): inTrash = theItem.itemParent == trashHandle and trashHandle is not None isTrash = theItem.itemHandle == trashHandle and trashHandle is not None isFile = theItem.itemType == nwItemType.FILE - isArch = theRoot.itemClass == nwItemClass.ARCHIVE isOrph = isFile and theItem.itemParent is None showOpen = isFile showView = isFile showEdit = not isTrash and not isOrph showExport = isFile - showNewFile = not (isTrash or inTrash or isOrph or isArch) + showNewFile = not (isTrash or inTrash or isOrph) showNewFolder = not (isTrash or inTrash or isOrph) showDelete = not isTrash showEmpty = isTrash diff --git a/nw/gui/theme.py b/nw/gui/theme.py index 2b5f31f5..c2b4b9fd 100644 --- a/nw/gui/theme.py +++ b/nw/gui/theme.py @@ -281,11 +281,11 @@ class GuiTheme: cnfSec = "Main" if confParser.has_section(cnfSec): self.themeName = self._parseLine(confParser, cnfSec, "name", "") - self.themeDescription = self._parseLine(confParser, cnfSec, "description", "") - self.themeAuthor = self._parseLine(confParser, cnfSec, "author", "") - self.themeCredit = self._parseLine(confParser, cnfSec, "credit", "") + self.themeDescription = self._parseLine(confParser, cnfSec, "description", "N/A") + self.themeAuthor = self._parseLine(confParser, cnfSec, "author", "N/A") + self.themeCredit = self._parseLine(confParser, cnfSec, "credit", "N/A") self.themeUrl = self._parseLine(confParser, cnfSec, "url", "") - self.themeLicense = self._parseLine(confParser, cnfSec, "license", "") + self.themeLicense = self._parseLine(confParser, cnfSec, "license", "N/A") self.themeLicenseUrl = self._parseLine(confParser, cnfSec, "licenseurl", "") ## Palette @@ -638,10 +638,10 @@ class GuiIcons: if confParser.has_section(cnfSec): self.themeName = self._parseLine(confParser, cnfSec, "name", "") self.themeDescription = self._parseLine(confParser, cnfSec, "description", "") - self.themeAuthor = self._parseLine(confParser, cnfSec, "author", "") - self.themeCredit = self._parseLine(confParser, cnfSec, "credit", "") + self.themeAuthor = self._parseLine(confParser, cnfSec, "author", "N/A") + self.themeCredit = self._parseLine(confParser, cnfSec, "credit", "N/A") self.themeUrl = self._parseLine(confParser, cnfSec, "url", "") - self.themeLicense = self._parseLine(confParser, cnfSec, "license", "") + self.themeLicense = self._parseLine(confParser, cnfSec, "license", "N/A") self.themeLicenseUrl = self._parseLine(confParser, cnfSec, "licenseurl", "") ## Palette diff --git a/nw/guimain.py b/nw/guimain.py index b4ff651a..9efc9a2e 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -81,14 +81,14 @@ class GuiMain(QMainWindow): # Core Classes # ============ - # Core Classes and settings + # Core Classes and Settings self.theTheme = GuiTheme(self) self.theProject = NWProject(self) self.theIndex = NWIndex(self.theProject, self) self.hasProject = False self.isFocusMode = False - # Prepare main window + # Prepare Main Window self.resize(*self.mainConf.getWinSize()) self._setWindowTitle() self.setWindowIcon(QIcon(self.mainConf.appIcon)) @@ -238,6 +238,12 @@ class GuiMain(QMainWindow): if self.mainConf.showGUI: self.showProjectLoadDialog() + # Show the latest release notes, if they haven't been shown before + if self.mainConf.lastNotes != nw.__version__: + if self.mainConf.showGUI: + self.showAboutNWDialog(showNotes=True) + self.mainConf.lastNotes = nw.__version__ + logger.debug("novelWriter is ready ...") self.setStatus("novelWriter is ready ...") @@ -915,11 +921,18 @@ class GuiMain(QMainWindow): return - def showAboutNWDialog(self): + def showAboutNWDialog(self, showNotes=False): """Show the about dialog for novelWriter. """ dlgAbout = GuiAbout(self) - dlgAbout.exec_() + dlgAbout.setModal(True) + dlgAbout.show() + qApp.processEvents() + dlgAbout.populateGUI() + + if showNotes: + dlgAbout.showReleaseNotes() + return def showAboutQtDialog(self): @@ -969,6 +982,13 @@ class GuiMain(QMainWindow): return + def askQuestion(self, theTitle, theQuestion): + """Ask the user a Yes/No question. + """ + msgBox = QMessageBox() + msgRes = msgBox.question(self, theTitle, theQuestion) + return msgRes == QMessageBox.Yes + def reportConfErr(self): """Checks if the Config module has any errors to report, and let the user know if this is the case. The Config module caches @@ -1060,10 +1080,10 @@ class GuiMain(QMainWindow): self.isFocusMode = not self.isFocusMode if self.isFocusMode: - logger.debug("Activating Focus mode") + logger.debug("Activating Focus Mode") self.tabWidget.setCurrentWidget(self.splitDocs) else: - logger.debug("Deactivating Focus mode") + logger.debug("Deactivating Focus Mode") isVisible = not self.isFocusMode self.treePane.setVisible(isVisible) diff --git a/pytest.ini b/pytest.ini index 0c3af3ba..c2278317 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,6 @@ [pytest] markers = - project: Project classes tests - error: Test various error handling scenarios + base: Base functionality tests core: Core functionality tests gui: Qt5 GUI tests serial diff --git a/setup/icons/novelwriter.ico b/setup/icons/novelwriter.ico index c96e8bdb..df883296 100644 Binary files a/setup/icons/novelwriter.ico and b/setup/icons/novelwriter.ico differ diff --git a/setup/icons/x-novelwriter-project.ico b/setup/icons/x-novelwriter-project.ico new file mode 100644 index 00000000..b349aa30 Binary files /dev/null and b/setup/icons/x-novelwriter-project.ico differ diff --git a/setup/win_setup.iss b/setup/win_setup.iss index 8b6be609..ba2c9990 100644 --- a/setup/win_setup.iss +++ b/setup/win_setup.iss @@ -27,11 +27,12 @@ UsedUserAreasWarning=no ;PrivilegesRequired=lowest PrivilegesRequiredOverridesAllowed=dialog OutputDir={#nwAppDir} -OutputBaseFilename=novelwriter_{#nwAppVersion}_win_amd64_setup +OutputBaseFilename=novelwriter-{#nwAppVersion}-win10-amd64-setup Compression=lzma SolidCompression=yes WizardStyle=modern ArchitecturesInstallIn64BitMode=x64 +ChangesAssociations=yes [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" @@ -50,3 +51,10 @@ Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#nwAppName}"; Fil [Run] Filename: "{app}\{#nwAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(nwAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + +[Registry] +Root: HKA; Subkey: "Software\Classes\.nwx\OpenWithProgids"; ValueType: string; ValueName: "novelWriterProject.nwx"; ValueData: ""; Flags: uninsdeletevalue +Root: HKA; Subkey: "Software\Classes\novelWriterProject.nwx"; ValueType: string; ValueName: ""; ValueData: "novelWriter Project File"; Flags: uninsdeletekey +Root: HKA; Subkey: "Software\Classes\novelWriterProject.nwx\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\assets\icons\x-novelwriter-project.ico" +Root: HKA; Subkey: "Software\Classes\novelWriterProject.nwx\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\novelWriter.exe"" ""%1""" +Root: HKA; Subkey: "Software\Classes\Applications\novelWriter.exe\SupportedTypes"; ValueType: string; ValueName: ".nwx"; ValueData: "" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..7d8e378d --- /dev/null +++ b/tests/README.md @@ -0,0 +1,78 @@ +# novelWriter Tests + +The test suite uses PyTest for testing. + +## Dependencies + +* `python3-pytest` for the basic framework (required) +* `python3-pytestqt` for Qt support (required) +* `python3-pytest-cov` for code coverage reports (optional) +* `python3-pytest-xvfb` for headless tests (optional) + +## HowTo + +### Basic Usage + +To run all tests, type: +```bash +pytest-3 -v +``` + +The `-v` switch enables verbose mode, with one test per line. +For a more compact view, omit this switch. + +### Headless + +To run tests in headless mode, either use the Qt `offscreen` mode: +```bash +export QT_QPA_PLATFORM=offscreen +``` + +or run with `xvfb`: +```bash +xvfb-run pytest-3 -v +``` + +### Test Coverage + +To add test coverage, run the following: +```bash +pytest-3 -v --cov=nw --cov-report=html +``` + +The `--cov-report` switch generates an html report, omit it to print a coverage summary to the terminal. +The html coverage report will be available in the `htmlcov` folder. + +### Test Markers (Categories) + +To run with specific test markers, add the `-m` switch: +```bash +pytest-3 -v -m core +``` + +Available markers are: + +* '`core`' for unit tests covering the classes in the `nw/core` folder + +## Tests + +To filter specific groups of tests, use the `-k` switch. +The commands for the respective test categories are listed below. + +| Type | Test Target | Source File(s) | Marker | Filter | +| :--- | :----------------- | :-------------------- | :-------- | :-------------------- | +| Unit | Main function | nw/\_\_init\_\_.py | `-m base` | `-k testBaseInit` | +| Unit | Common functions | nw/common.py | `-m base` | `-k testBaseCommon` | +| Unit | Config class | nw/config.py | `-m base` | `-k testBaseConfig` | +| Unit | Error handlers | nw/error.py | `-m base` | `-k testBaseError` | +| Unit | Core functions | nw/core/tools.py | `-m core` | `-k testCoreTools` | +| Unit | NWDoc class | nw/core/document.py | `-m core` | `-k testCoreDocument` | +| Unit | NWIndex class | nw/core/index.py | `-m core` | `-k testCoreIndex` | +| Unit | NWItem class | nw/core/item.py | `-m core` | `-k testCoreItem` | +| Unit | NWProject class | nw/core/project.py | `-m core` | `-k testCoreProject` | +| Unit | NWSpell* classes | nw/core/spellcheck.py | `-m core` | `-k testCoreSpell` | +| Unit | NWStatus class | nw/core/status.py | `-m core` | `-k testCoreStatus` | +| Unit | NWTree class | nw/core/tree.py | `-m core` | `-k testCoreTree` | +| Unit | OptionsState class | nw/core/options.py | `-m core` | `-k testCoreOptions` | +| Unit | ToHtml class | nw/core/tohtml.py | `-m core` | `-k testCoreToHtml` | +| Unit | Tokenizer class | nw/core/tokenizer.py | `-m core` | `-k testCoreToken` | diff --git a/tests/conftest.py b/tests/conftest.py index b8cc2fef..ea9a92b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,7 @@ import pytest import shutil import os -from nwdummy import DummyMain +from dummy import DummyMain from PyQt5.QtWidgets import QMessageBox @@ -20,94 +20,42 @@ from nw.config import Config # noqa: E402 ## @pytest.fixture(scope="session") -def nwTemp(): +def tmpDir(): """A temporary folder for the test session. This folder is presistent after the test so that the status of generated files can be checked. The folder is instead cleared before a new test session. """ testDir = os.path.dirname(__file__) - tempDir = os.path.join(testDir, "temp") - if os.path.isdir(tempDir): - shutil.rmtree(tempDir) - if not os.path.isdir(tempDir): - os.mkdir(tempDir) - return tempDir + theDir = os.path.join(testDir, "temp") + if os.path.isdir(theDir): + shutil.rmtree(theDir) + if not os.path.isdir(theDir): + os.mkdir(theDir) + return theDir @pytest.fixture(scope="session") -def nwRef(): +def refDir(): """The folder where all the reference files are stored for verifying the results of tests. """ testDir = os.path.dirname(__file__) - refDir = os.path.join(testDir, "reference") - return refDir - -## -# novelWriter Objects -## + theDir = os.path.join(testDir, "reference") + return theDir @pytest.fixture(scope="session") -def tmpConf(nwTemp): - """Create a temporary novelWriter configuration object. +def outDir(tmpDir): + """An output folder for test results """ - theConf = Config() - theConf.initConfig(nwTemp, nwTemp) - theConf.setLastPath("") - return theConf - -@pytest.fixture(scope="session") -def nwConf(nwRef, nwTemp): - """Temporary novelWriter configuration used for the dummy instance - of novelWriter's main GUI. - """ - theConf = Config() - theConf.initConfig(nwRef, nwTemp) - return theConf - -@pytest.fixture(scope="session") -def nwDummy(nwRef, nwTemp, nwConf): - """Create a dummy instance of novelWriter's main GUI class. - """ - theDummy = DummyMain() - theDummy.mainConf = nwConf - return theDummy - -## -# Temporary Test Folders -## - -@pytest.fixture(scope="session") -def nwTempProj(nwTemp): - """A temporary folder for project tests. - """ - projDir = os.path.join(nwTemp, "proj") - if not os.path.isdir(projDir): - os.mkdir(projDir) - return projDir - -@pytest.fixture(scope="session") -def nwTempGUI(nwTemp): - """A temporary folder for GUI tests. - """ - guiDir = os.path.join(nwTemp, "gui") - if not os.path.isdir(guiDir): - os.mkdir(guiDir) - return guiDir - -@pytest.fixture(scope="session") -def nwTempBuild(nwTemp): - """A temporary folder for build tests. - """ - buildDir = os.path.join(nwTemp, "build") - if not os.path.isdir(buildDir): - os.mkdir(buildDir) - return buildDir + theDir = os.path.join(tmpDir, "results") + if not os.path.isdir(theDir): + os.mkdir(theDir) + return theDir @pytest.fixture(scope="function") -def nwFuncTemp(nwTemp): +def fncDir(tmpDir): """A temporary folder for a single test function. """ - funcDir = os.path.join(nwTemp, "ftemp") + funcDir = os.path.join(tmpDir, "ftemp") if os.path.isdir(funcDir): shutil.rmtree(funcDir) if not os.path.isdir(funcDir): @@ -118,16 +66,40 @@ def nwFuncTemp(nwTemp): return ## -# Temp Folders for Projects +# novelWriter Objects ## @pytest.fixture(scope="function") -def nwMinimal(nwTemp): +def tmpConf(tmpDir): + """Create a temporary novelWriter configuration object. + """ + confFile = os.path.join(tmpDir, "novelwriter.conf") + if os.path.isfile(confFile): + os.unlink(confFile) + theConf = Config() + theConf.initConfig(tmpDir, tmpDir) + theConf.setLastPath("") + return theConf + +@pytest.fixture(scope="function") +def dummyGUI(tmpConf): + """Create a dummy instance of novelWriter's main GUI class. + """ + theDummy = DummyMain() + theDummy.mainConf = tmpConf + return theDummy + +## +# Temp Project Folders +## + +@pytest.fixture(scope="function") +def nwMinimal(tmpDir): """A minimal novelWriter example project. """ testDir = os.path.dirname(__file__) minimalStore = os.path.join(testDir, "minimal") - minimalDir = os.path.join(nwTemp, "minimal") + minimalDir = os.path.join(tmpDir, "minimal") if os.path.isdir(minimalDir): shutil.rmtree(minimalDir) shutil.copytree(minimalStore, minimalDir) @@ -143,13 +115,13 @@ def nwMinimal(nwTemp): return @pytest.fixture(scope="function") -def nwLipsum(nwTemp): +def nwLipsum(tmpDir): """A medium sized novelWriter example project with a lot of Lorem Ipsum dummy text. """ testDir = os.path.dirname(__file__) lipsumStore = os.path.join(testDir, "lipsum") - lipsumDir = os.path.join(nwTemp, "lipsum") + lipsumDir = os.path.join(tmpDir, "lipsum") if os.path.isdir(lipsumDir): shutil.rmtree(lipsumDir) shutil.copytree(lipsumStore, lipsumDir) @@ -165,12 +137,12 @@ def nwLipsum(nwTemp): return @pytest.fixture(scope="function") -def nwOldProj(nwTemp): +def nwOldProj(tmpDir): """A minimal movelWriter project using the old folder structure. """ testDir = os.path.dirname(__file__) oldProjStore = os.path.join(testDir, "oldproj") - oldProjDir = os.path.join(nwTemp, "oldproj") + oldProjDir = os.path.join(tmpDir, "oldproj") if os.path.isdir(oldProjDir): shutil.rmtree(oldProjDir) shutil.copytree(oldProjStore, oldProjDir) @@ -199,4 +171,30 @@ def yesToAll(monkeypatch): monkeypatch.setattr( QMessageBox, "critical", lambda *args, **kwargs: QMessageBox.Yes ) + yield + monkeypatch.undo() return + +# =============================================================================================== # + +## +# Temporary Test Folders +## + +@pytest.fixture(scope="session") +def nwTempGUI(tmpDir): + """A temporary folder for GUI tests. + """ + guiDir = os.path.join(tmpDir, "gui") + if not os.path.isdir(guiDir): + os.mkdir(guiDir) + return guiDir + +@pytest.fixture(scope="session") +def nwTempBuild(tmpDir): + """A temporary folder for build tests. + """ + buildDir = os.path.join(tmpDir, "build") + if not os.path.isdir(buildDir): + os.mkdir(buildDir) + return buildDir diff --git a/tests/dummy.py b/tests/dummy.py new file mode 100644 index 00000000..daf0e070 --- /dev/null +++ b/tests/dummy.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +"""novelWriter Test Dummy GUI Classes +""" + +# =========================================================================== # +# Mock GUI +# =========================================================================== # + +class DummyMain(): + + def __init__(self): + self.mainConf = None + self.hasProject = True + self.theIndex = None + self.theProject = None + self.statusBar = DummyStatusBar() + + # Test Variables + self.askResponse = True + self.lastAlert = "" + + return + + def makeAlert(self, theMessage, theLevel): + print("%s: %s" % (str(theLevel), theMessage)) + self.lastAlert = str(theMessage) + return + + def askQuestion(self, theTitle, theQustion): + print("Question: %s" % theQustion) + return self.askResponse + + def setStatus(self, theMessage): + return + + def setProjectStatus(self, isChanged): + return + + def openProject(self, projPath): + return + + def rebuildIndex(self): + return + + def closeMain(self): + return "closeMain" + + def close(self): + return "close" + + # Test Functions + + def undo(self): + self.askResponse = True + return + + def clear(self): + self.lastAlert = "" + return + +# END Class DummyMain + +class DummyStatusBar(): + + def __init__(self): + return + + def setStatus(self, theText): + return + +# END Class DummyStatusBar + +# =========================================================================== # +# Error Functions +# Dummy functions that will raise errors instead. +# =========================================================================== # + +def causeOSError(*args, **kwargs): + raise OSError + +def causeException(*args, **kwargs): + raise Exception diff --git a/tests/lipsum/ToC.json b/tests/lipsum/ToC.json deleted file mode 100644 index 4d22a21c..00000000 --- a/tests/lipsum/ToC.json +++ /dev/null @@ -1,77 +0,0 @@ -[ - [ - "content/04468803b92e1.nwd", - "WORLD", - "Ancient Europe" - ], - [ - "content/2426c6f0ca922.nwd", - "PLOT", - "Main" - ], - [ - "content/441420a886d82.nwd", - "NOVEL", - "Chapter Two" - ], - [ - "content/47666c91c7ccf.nwd", - "NOVEL", - "Scene Five" - ], - [ - "content/4c4f28287af27.nwd", - "CHARACTER", - "Mr. Nobody" - ], - [ - "content/7a992350f3eb6.nwd", - "NOVEL", - "Lorem Ipsum" - ], - [ - "content/846352075de7d.nwd", - "NOVEL", - "Interlude" - ], - [ - "content/88243afbe5ed8.nwd", - "NOVEL", - "Scene One" - ], - [ - "content/88d59a277361b.nwd", - "NOVEL", - "Prologue" - ], - [ - "content/8c58a65414c23.nwd", - "NOVEL", - "Front Matter" - ], - [ - "content/db7e733775d4d.nwd", - "NOVEL", - "Act One" - ], - [ - "content/eb103bc70c90c.nwd", - "NOVEL", - "Scene Three" - ], - [ - "content/f8c0562e50f1b.nwd", - "NOVEL", - "Scene Four" - ], - [ - "content/f96ec11c6a3da.nwd", - "NOVEL", - "Scene Two" - ], - [ - "content/fb609cd8319dc.nwd", - "NOVEL", - "Chapter One" - ] -] \ No newline at end of file diff --git a/tests/minimal/ToC.json b/tests/minimal/ToC.json deleted file mode 100644 index 5881c392..00000000 --- a/tests/minimal/ToC.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - [ - "content/8c659a11cd429.nwd", - "NOVEL", - "New Scene" - ], - [ - "content/a35baf2e93843.nwd", - "NOVEL", - "Title Page" - ], - [ - "content/f5ab3e30151e1.nwd", - "NOVEL", - "New Chapter" - ] -] \ No newline at end of file diff --git a/tests/nwdummy.py b/tests/nwdummy.py deleted file mode 100644 index 4b5302b7..00000000 --- a/tests/nwdummy.py +++ /dev/null @@ -1,39 +0,0 @@ -# -*- coding: utf-8 -*- -"""novelWriter Test Dummy GUI Classes -""" - -class DummyMain(): - - def __init__(self): - self.mainConf = None - self.hasProject = True - self.statusBar = StatusBar() - return - - def makeAlert(self, theMessage, theLevel): - print("%s: %s" % (str(theLevel), theMessage)) - return - - def setStatus(self, theMessage): - return - - def setProjectStatus(self, isChanged): - return - - def openProject(self, projPath): - return - - def rebuildIndex(self): - return - -# END Class GuiMain - -class StatusBar(): - - def __init__(self): - return - - def setStatus(self, theText): - return - -# END Class StatusBar diff --git a/tests/reference/novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf similarity index 98% rename from tests/reference/novelwriter.conf rename to tests/reference/baseConfig_novelwriter.conf index 99b01f75..5ff6f852 100644 --- a/tests/reference/novelwriter.conf +++ b/tests/reference/baseConfig_novelwriter.conf @@ -6,6 +6,7 @@ icons = typicons_colour_light guidark = False guifont = guifontsize = 11 +lastnotes = 1.0 [Sizes] geometry = 1200, 650 diff --git a/tests/reference/proj/1_tagsIndex.json b/tests/reference/coreIndex_LoadSave_tagsIndex.json similarity index 100% rename from tests/reference/proj/1_tagsIndex.json rename to tests/reference/coreIndex_LoadSave_tagsIndex.json diff --git a/tests/reference/proj/4_nwProject.nwx b/tests/reference/coreProject_NewCustomA_nwProject.nwx similarity index 81% rename from tests/reference/proj/4_nwProject.nwx rename to tests/reference/coreProject_NewCustomA_nwProject.nwx index 4c9c30eb..fc7e7ab2 100644 --- a/tests/reference/proj/4_nwProject.nwx +++ b/tests/reference/coreProject_NewCustomA_nwProject.nwx @@ -41,56 +41,56 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + Locations ROOT WORLD New False - + Timeline ROOT TIMELINE New False - + Objects ROOT OBJECT New False - + Entity ROOT ENTITY New False - + Title Page FILE NOVEL @@ -102,14 +102,14 @@ 0 0 - + Chapter 1 FOLDER NOVEL New False - + Chapter 1 FILE NOVEL @@ -121,7 +121,7 @@ 0 0 - + Scene 1.1 FILE NOVEL @@ -133,7 +133,7 @@ 0 0 - + Scene 1.2 FILE NOVEL @@ -145,7 +145,7 @@ 0 0 - + Scene 1.3 FILE NOVEL @@ -157,14 +157,14 @@ 0 0 - + Chapter 2 FOLDER NOVEL New False - + Chapter 2 FILE NOVEL @@ -176,7 +176,7 @@ 0 0 - + Scene 2.1 FILE NOVEL @@ -188,7 +188,7 @@ 0 0 - + Scene 2.2 FILE NOVEL @@ -200,7 +200,7 @@ 0 0 - + Scene 2.3 FILE NOVEL @@ -212,14 +212,14 @@ 0 0 - + Chapter 3 FOLDER NOVEL New False - + Chapter 3 FILE NOVEL @@ -231,7 +231,7 @@ 0 0 - + Scene 3.1 FILE NOVEL @@ -243,7 +243,7 @@ 0 0 - + Scene 3.2 FILE NOVEL @@ -255,7 +255,7 @@ 0 0 - + Scene 3.3 FILE NOVEL diff --git a/tests/reference/proj/5_nwProject.nwx b/tests/reference/coreProject_NewCustomB_nwProject.nwx similarity index 83% rename from tests/reference/proj/5_nwProject.nwx rename to tests/reference/coreProject_NewCustomB_nwProject.nwx index 9935736e..11d007aa 100644 --- a/tests/reference/proj/5_nwProject.nwx +++ b/tests/reference/coreProject_NewCustomB_nwProject.nwx @@ -41,56 +41,56 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + Locations ROOT WORLD New False - + Timeline ROOT TIMELINE New False - + Objects ROOT OBJECT New False - + Entity ROOT ENTITY New False - + Title Page FILE NOVEL @@ -102,7 +102,7 @@ 0 0 - + Scene 1 FILE NOVEL @@ -114,7 +114,7 @@ 0 0 - + Scene 2 FILE NOVEL @@ -126,7 +126,7 @@ 0 0 - + Scene 3 FILE NOVEL @@ -138,7 +138,7 @@ 0 0 - + Scene 4 FILE NOVEL @@ -150,7 +150,7 @@ 0 0 - + Scene 5 FILE NOVEL @@ -162,7 +162,7 @@ 0 0 - + Scene 6 FILE NOVEL diff --git a/tests/reference/proj/3_nwProject.nwx b/tests/reference/coreProject_NewFile_nwProject.nwx similarity index 84% rename from tests/reference/proj/3_nwProject.nwx rename to tests/reference/coreProject_NewFile_nwProject.nwx index c9e450d6..66488434 100644 --- a/tests/reference/proj/3_nwProject.nwx +++ b/tests/reference/coreProject_NewFile_nwProject.nwx @@ -39,35 +39,35 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + World ROOT WORLD New False - + Title Page FILE NOVEL @@ -79,14 +79,14 @@ 0 0 - + New Chapter FOLDER NOVEL New False - + New Chapter FILE NOVEL @@ -98,7 +98,7 @@ 0 0 - + New Scene FILE NOVEL @@ -110,7 +110,7 @@ 0 0 - + Hello FILE NOVEL @@ -122,7 +122,7 @@ 0 0 - + Jane FILE CHARACTER diff --git a/tests/reference/proj/1_nwProject.nwx b/tests/reference/coreProject_NewMinimal_nwProject.nwx similarity index 85% rename from tests/reference/proj/1_nwProject.nwx rename to tests/reference/coreProject_NewMinimal_nwProject.nwx index 61ece12f..d8b774bb 100644 --- a/tests/reference/proj/1_nwProject.nwx +++ b/tests/reference/coreProject_NewMinimal_nwProject.nwx @@ -39,35 +39,35 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + World ROOT WORLD New False - + Title Page FILE NOVEL @@ -79,14 +79,14 @@ 0 0 - + New Chapter FOLDER NOVEL New False - + New Chapter FILE NOVEL @@ -98,7 +98,7 @@ 0 0 - + New Scene FILE NOVEL diff --git a/tests/reference/proj/2_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx similarity index 82% rename from tests/reference/proj/2_nwProject.nwx rename to tests/reference/coreProject_NewRoot_nwProject.nwx index cc7b54ea..9ea0617f 100644 --- a/tests/reference/proj/2_nwProject.nwx +++ b/tests/reference/coreProject_NewRoot_nwProject.nwx @@ -39,35 +39,35 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + World ROOT WORLD New False - + Title Page FILE NOVEL @@ -79,14 +79,14 @@ 0 0 - + New Chapter FOLDER NOVEL New False - + New Chapter FILE NOVEL @@ -98,7 +98,7 @@ 0 0 - + New Scene FILE NOVEL @@ -110,28 +110,28 @@ 0 0 - + Timeline ROOT TIMELINE New False - + Object ROOT OBJECT New False - + Custom1 ROOT CUSTOM New False - + Custom2 ROOT CUSTOM diff --git a/tests/reference/novelwriter_prefs.conf b/tests/reference/novelwriter_prefs.conf index 831bbf08..901a6937 100644 --- a/tests/reference/novelwriter_prefs.conf +++ b/tests/reference/novelwriter_prefs.conf @@ -6,6 +6,7 @@ icons = typicons_colour_light guidark = True guifont = Cantarell guifontsize = 12 +lastnotes = 1.0 [Sizes] geometry = 1100, 650 diff --git a/tests/test_common.py b/tests/test_base_common.py similarity index 78% rename from tests/test_common.py rename to tests/test_base_common.py index 2a24f31f..8cdb8a74 100644 --- a/tests/test_common.py +++ b/tests/test_base_common.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""novelWriter Common Class Tester +"""novelWriter Common Functions Tester """ import time @@ -9,10 +9,12 @@ from nw.common import ( checkString, checkBool, checkInt, colRange, formatInt, transferCase, fuzzyTime, checkHandle, formatTimeStamp, formatTime ) -from nwtools import cmpList +from tools import cmpList -@pytest.mark.core -def testCheckString(): +@pytest.mark.base +def testBaseCommon_CheckString(): + """Test the checkString function. + """ assert checkString(None, "NotNone", True) is None assert checkString("None", "NotNone", True) is None assert checkString("None", "NotNone", False) == "None" @@ -21,8 +23,12 @@ def testCheckString(): assert checkString(1.0, "NotNone", False) == "NotNone" assert checkString(True, "NotNone", False) == "NotNone" -@pytest.mark.core -def testCheckInt(): +# END Test testBaseCommon_CheckString + +@pytest.mark.base +def testBaseCommon_CheckInt(): + """Test the checkInt function. + """ assert checkInt(None, 3, True) is None assert checkInt("None", 3, True) is None assert checkInt(None, 3, False) == 3 @@ -30,8 +36,12 @@ def testCheckInt(): assert checkInt(1.0, 3, False) == 1 assert checkInt(True, 3, False) == 1 -@pytest.mark.core -def testCheckBool(): +# END Test testBaseCommon_CheckInt + +@pytest.mark.base +def testBaseCommon_CheckBool(): + """Test the checkBool function. + """ assert checkBool(None, 3, True) is None assert checkBool("None", 3, True) is None assert checkBool("True", False, False) @@ -44,8 +54,12 @@ def testCheckBool(): assert checkBool(1.0, None, False) is None assert checkBool(2.0, None, False) is None -@pytest.mark.core -def testCheckHandle(): +# END Test testBaseCommon_CheckBool + +@pytest.mark.base +def testBaseCommon_CheckHandle(): + """Test the checkHandle function. + """ assert checkHandle("None", 1, True) is None assert checkHandle("None", 1, False) == 1 assert checkHandle(None, 1, True) is None @@ -53,8 +67,12 @@ def testCheckHandle(): assert checkHandle("47666c91c7ccf", None, False) == "47666c91c7ccf" assert checkHandle("h7666c91c7ccf", None, False) is None -@pytest.mark.core -def testColRange(): +# END Test testBaseCommon_CheckHandle + +@pytest.mark.base +def testBaseCommon_ColRange(): + """Test the colRange function. + """ assert colRange([0, 0], [0, 0], 0) is None assert cmpList( colRange([200, 50, 0], [50, 200, 0], 1), @@ -77,14 +95,22 @@ def testColRange(): [[200, 50, 0], [162, 87, 0], [124, 124, 0], [86, 161, 0], [50, 200, 0]] ) -@pytest.mark.core -def testFormatTimeStamp(): +# END Test testBaseCommon_ColRange + +@pytest.mark.base +def testBaseCommon_FormatTimeStamp(): + """Test the formatTimeStamp function. + """ tTime = time.mktime(time.gmtime(0)) assert formatTimeStamp(tTime, False) == "1970-01-01 00:00:00" assert formatTimeStamp(tTime, True) == "1970-01-01 00.00.00" -@pytest.mark.core -def testFormatTime(): +# END Test testBaseCommon_FormatTimeStamp + +@pytest.mark.base +def testBaseCommon_FormatTime(): + """Test the formatTime function. + """ assert formatTime("1") == "ERROR" assert formatTime(1.0) == "ERROR" assert formatTime(1) == "00:00:01" @@ -101,8 +127,12 @@ def testFormatTime(): assert formatTime(86400) == "1-00:00:00" assert formatTime(360000) == "4-04:00:00" -@pytest.mark.core -def testFormatInt(): +# END Test testBaseCommon_FormatTime + +@pytest.mark.base +def testBaseCommon_FormatInt(): + """Test the formatInt function. + """ assert formatInt(1000) == "1000" assert formatInt(1234) == "1.23\u2009k" assert formatInt(12345) == "12.3\u2009k" @@ -112,8 +142,12 @@ def testFormatInt(): assert formatInt(123456789) == "123\u2009M" assert formatInt(1234567890) == "1.23\u2009G" -@pytest.mark.core -def testTransferCase(): +# END Test testBaseCommon_FormatInt + +@pytest.mark.base +def testBaseCommon_TransferCase(): + """Test the transferCase function. + """ assert transferCase(1, "TaRgEt") == "TaRgEt" assert transferCase("source", 1) == 1 assert transferCase("", "TaRgEt") == "TaRgEt" @@ -122,8 +156,12 @@ def testTransferCase(): assert transferCase("SOURCE", "target") == "TARGET" assert transferCase("source", "TARGET") == "target" -@pytest.mark.core -def testFuzzyTime(): +# END Test testBaseCommon_TransferCase + +@pytest.mark.base +def testBaseCommon_FuzzyTime(): + """Test the fuzzyTime function. + """ assert fuzzyTime(-1) == "in the future" assert fuzzyTime(0) == "just now" assert fuzzyTime(29) == "just now" @@ -152,3 +190,5 @@ def testFuzzyTime(): assert fuzzyTime(29808000) == "a year ago" assert fuzzyTime(47336399) == "a year ago" assert fuzzyTime(47336400) == "2 years ago" + +# END Test testBaseCommon_FuzzyTime diff --git a/tests/test_base_config.py b/tests/test_base_config.py new file mode 100644 index 00000000..fe755646 --- /dev/null +++ b/tests/test_base_config.py @@ -0,0 +1,505 @@ +# -*- coding: utf-8 -*- +"""novelWriter Config Class Tester +""" + +import pytest +import sys +import os +import configparser + +from shutil import copyfile + +from dummy import causeOSError +from tools import cmpFiles + +from nw.config import Config +from nw.constants import nwConst, nwFiles + +@pytest.mark.base +def testBaseConfig_Constructor(monkeypatch): + """Test config contructor. + """ + # Linux + monkeypatch.setattr("sys.platform", "linux") + tstConf = Config() + assert tstConf.osLinux is True + assert tstConf.osDarwin is False + assert tstConf.osWindows is False + assert tstConf.osUnknown is False + monkeypatch.undo() + + # macOS + monkeypatch.setattr("sys.platform", "darwin") + tstConf = Config() + assert tstConf.osLinux is False + assert tstConf.osDarwin is True + assert tstConf.osWindows is False + assert tstConf.osUnknown is False + monkeypatch.undo() + + # Windows + monkeypatch.setattr("sys.platform", "win32") + tstConf = Config() + assert tstConf.osLinux is False + assert tstConf.osDarwin is False + assert tstConf.osWindows is True + assert tstConf.osUnknown is False + monkeypatch.undo() + + # Cygwin + monkeypatch.setattr("sys.platform", "cygwin") + tstConf = Config() + assert tstConf.osLinux is False + assert tstConf.osDarwin is False + assert tstConf.osWindows is True + assert tstConf.osUnknown is False + monkeypatch.undo() + + # Other + monkeypatch.setattr("sys.platform", "some_ther_os") + tstConf = Config() + assert tstConf.osLinux is False + assert tstConf.osDarwin is False + assert tstConf.osWindows is False + assert tstConf.osUnknown is True + monkeypatch.undo() + +# END Test testBaseConfig_Constructor + +@pytest.mark.base +def testBaseConfig_Init(monkeypatch, tmpDir, fncDir, outDir, refDir): + """Test config intialisation. + """ + tstConf = Config() + + confFile = os.path.join(tmpDir, "novelwriter.conf") + testFile = os.path.join(outDir, "baseConfig_novelwriter.conf") + compFile = os.path.join(refDir, "baseConfig_novelwriter.conf") + + # Make sure we don't have any old conf file + if os.path.isfile(confFile): + os.unlink(confFile) + + # Let the config class figure out the path + monkeypatch.setattr("PyQt5.QtCore.QStandardPaths.writableLocation", lambda *args: fncDir) + tstConf.verQtValue = 50600 + tstConf.initConfig() + assert tstConf.confPath == os.path.join(fncDir, tstConf.appHandle) + assert tstConf.dataPath == os.path.join(fncDir, tstConf.appHandle) + assert not os.path.isfile(confFile) + tstConf.verQtValue = 50000 + tstConf.initConfig() + assert tstConf.confPath == os.path.join(fncDir, tstConf.appHandle) + assert tstConf.dataPath == os.path.join(fncDir, tstConf.appHandle) + assert not os.path.isfile(confFile) + monkeypatch.undo() + + # Fail to make folders + monkeypatch.setattr("os.mkdir", causeOSError) + + tstConfDir = os.path.join(fncDir, "test_conf") + tstConf.initConfig(confPath=tstConfDir, dataPath=tmpDir) + assert tstConf.confPath is None + assert tstConf.dataPath == tmpDir + assert not os.path.isfile(confFile) + + tstDataDir = os.path.join(fncDir, "test_data") + tstConf.initConfig(confPath=tmpDir, dataPath=tstDataDir) + assert tstConf.confPath == tmpDir + assert tstConf.dataPath is None + assert os.path.isfile(confFile) + os.unlink(confFile) + + monkeypatch.undo() + + # Test load/save with no path + tstConf.confPath = None + assert not tstConf.loadConfig() + assert not tstConf.saveConfig() + + # Run again and set the paths directly and correctly + # This should create a config file as well + monkeypatch.setattr("os.path.expanduser", lambda *args: "") + tstConf.spellTool = nwConst.SP_INTERNAL + tstConf.initConfig(confPath=tmpDir, dataPath=tmpDir) + assert tstConf.confPath == tmpDir + assert tstConf.dataPath == tmpDir + assert os.path.isfile(confFile) + + copyfile(confFile, testFile) + assert cmpFiles(testFile, compFile, [2, 9]) + monkeypatch.undo() + + # Load and save with OSError + monkeypatch.setattr("builtins.open", causeOSError) + + assert not tstConf.loadConfig() + assert tstConf.hasError is True + assert tstConf.errData != [] + assert tstConf.getErrData().startswith("Could not") + assert tstConf.hasError is False + assert tstConf.errData == [] + + assert not tstConf.saveConfig() + assert tstConf.hasError is True + assert tstConf.errData != [] + assert tstConf.getErrData().startswith("Could not") + assert tstConf.hasError is False + assert tstConf.errData == [] + + monkeypatch.undo() + + assert tstConf.loadConfig() + assert tstConf.saveConfig() + + copyfile(confFile, testFile) + assert cmpFiles(testFile, compFile, [2, 9]) + +# END Test testBaseConfig_Init + +@pytest.mark.base +def testBaseConfig_RecentCache(monkeypatch, tmpConf, tmpDir, fncDir): + """Test recent cache file. + """ + # Check failing + tmpConf.dataPath = None + assert not tmpConf.loadRecentCache() + assert not tmpConf.saveRecentCache() + tmpConf.dataPath = tmpDir + + # Add a couple of values + pathOne = os.path.join(fncDir, "projPathOne", nwFiles.PROJ_FILE) + pathTwo = os.path.join(fncDir, "projPathTwo", nwFiles.PROJ_FILE) + assert tmpConf.updateRecentCache(pathOne, "Proj One", 100, 1600002000) + assert tmpConf.updateRecentCache(pathTwo, "Proj Two", 200, 1600005600) + assert tmpConf.recentProj == { + pathOne: {"time": 1600002000, "title": "Proj One", "words": 100}, + pathTwo: {"time": 1600005600, "title": "Proj Two", "words": 200}, + } + + # Fail to Save + monkeypatch.setattr("builtins.open", causeOSError) + assert not tmpConf.saveRecentCache() + monkeypatch.undo() + + # Save Proper + cacheFile = os.path.join(tmpDir, nwFiles.RECENT_FILE) + assert tmpConf.saveRecentCache() + assert tmpConf.saveRecentCache() + assert os.path.isfile(cacheFile) + + # Fail to Load + monkeypatch.setattr("builtins.open", causeOSError) + tmpConf.recentProj = {} + assert not tmpConf.loadRecentCache() + assert tmpConf.recentProj == {} + monkeypatch.undo() + + # Load Proper + tmpConf.recentProj = {} + assert tmpConf.loadRecentCache() + assert tmpConf.recentProj == { + pathOne: {"time": 1600002000, "title": "Proj One", "words": 100}, + pathTwo: {"time": 1600005600, "title": "Proj Two", "words": 200}, + } + + # Remove Non-Existent Entry + assert not tmpConf.removeFromRecentCache("dummy") + assert tmpConf.recentProj == { + pathOne: {"time": 1600002000, "title": "Proj One", "words": 100}, + pathTwo: {"time": 1600005600, "title": "Proj Two", "words": 200}, + } + + # Remove Second Entry + assert tmpConf.removeFromRecentCache(pathTwo) + assert tmpConf.recentProj == { + pathOne: {"time": 1600002000, "title": "Proj One", "words": 100}, + } + +# END Test testBaseConfig_RecentCache + +@pytest.mark.base +def testBaseConfig_SetPath(tmpConf, tmpDir): + """Test path setters. + """ + # Conf Path + assert tmpConf.setConfPath(None) + assert not tmpConf.setConfPath(os.path.join("somewhere", "over", "the", "rainbow")) + assert tmpConf.setConfPath(os.path.join(tmpDir, "novelwriter.conf")) + assert tmpConf.confPath == tmpDir + assert tmpConf.confFile == "novelwriter.conf" + assert not tmpConf.confChanged + + # Data Path + assert tmpConf.setDataPath(None) + assert not tmpConf.setDataPath(os.path.join("somewhere", "over", "the", "rainbow")) + assert tmpConf.setDataPath(tmpDir) + assert tmpConf.dataPath == tmpDir + assert not tmpConf.confChanged + + # Last Path + assert tmpConf.setLastPath(None) + assert tmpConf.lastPath == "" + + assert tmpConf.setLastPath(os.path.join(tmpDir, "file.tmp")) + assert tmpConf.lastPath == tmpDir + + assert tmpConf.setLastPath("") + assert tmpConf.lastPath == "" + +# END Test testBaseConfig_SetPath + +@pytest.mark.base +def testBaseConfig_SettersGetters(tmpConf, tmpDir, outDir, refDir): + """Set various sizes and positions + """ + confFile = os.path.join(tmpDir, "novelwriter.conf") + testFile = os.path.join(outDir, "baseConfig_novelwriter.conf") + compFile = os.path.join(refDir, "baseConfig_novelwriter.conf") + + # GUI Scaling + # =========== + tmpConf.guiScale = 1.0 + assert tmpConf.pxInt(10) == 10 + assert tmpConf.pxInt(13) == 13 + assert tmpConf.rpxInt(10) == 10 + assert tmpConf.rpxInt(13) == 13 + + tmpConf.guiScale = 2.0 + assert tmpConf.pxInt(10) == 20 + assert tmpConf.pxInt(13) == 26 + assert tmpConf.rpxInt(10) == 5 + assert tmpConf.rpxInt(13) == 6 + + # Setter + Getter Combos + # ====================== + + # Window Size + tmpConf.guiScale = 1.0 + assert tmpConf.setWinSize(1205, 655) + assert not tmpConf.confChanged + + tmpConf.guiScale = 2.0 + assert tmpConf.setWinSize(70, 70) + assert tmpConf.getWinSize() == [70, 70] + assert tmpConf.winGeometry == [35, 35] + + tmpConf.guiScale = 1.0 + assert tmpConf.setWinSize(70, 70) + assert tmpConf.getWinSize() == [70, 70] + assert tmpConf.winGeometry == [70, 70] + + assert tmpConf.setWinSize(1200, 650) + + # Project Tree Columns + tmpConf.guiScale = 2.0 + assert tmpConf.setTreeColWidths([10, 20, 25]) + assert tmpConf.getTreeColWidths() == [10, 20, 24] + assert tmpConf.treeColWidth == [5, 10, 12] + + tmpConf.guiScale = 1.0 + assert tmpConf.setTreeColWidths([10, 20, 25]) + assert tmpConf.getTreeColWidths() == [10, 20, 25] + assert tmpConf.treeColWidth == [10, 20, 25] + + assert tmpConf.setTreeColWidths([200, 50, 30]) + + # Project Settings Tree Columns + tmpConf.guiScale = 2.0 + assert tmpConf.setProjColWidths([10, 20, 30]) + assert tmpConf.getProjColWidths() == [10, 20, 30] + assert tmpConf.projColWidth == [5, 10, 15] + + tmpConf.guiScale = 1.0 + assert tmpConf.setProjColWidths([10, 20, 30]) + assert tmpConf.getProjColWidths() == [10, 20, 30] + assert tmpConf.projColWidth == [10, 20, 30] + + assert tmpConf.setProjColWidths([200, 60, 140]) + + # Main Pane Splitter + tmpConf.guiScale = 2.0 + assert tmpConf.setMainPanePos([200, 700]) + assert tmpConf.getMainPanePos() == [200, 700] + assert tmpConf.mainPanePos == [100, 350] + + tmpConf.guiScale = 1.0 + assert tmpConf.setMainPanePos([200, 700]) + assert tmpConf.getMainPanePos() == [200, 700] + assert tmpConf.mainPanePos == [200, 700] + + assert tmpConf.setMainPanePos([300, 800]) + + # Doc Pane Splitter + tmpConf.guiScale = 2.0 + assert tmpConf.setDocPanePos([300, 300]) + assert tmpConf.getDocPanePos() == [300, 300] + assert tmpConf.docPanePos == [150, 150] + + tmpConf.guiScale = 1.0 + assert tmpConf.setDocPanePos([300, 300]) + assert tmpConf.getDocPanePos() == [300, 300] + assert tmpConf.docPanePos == [300, 300] + + assert tmpConf.setDocPanePos([400, 400]) + + # View Pane Splitter + tmpConf.guiScale = 2.0 + assert tmpConf.setViewPanePos([400, 250]) + assert tmpConf.getViewPanePos() == [400, 250] + assert tmpConf.viewPanePos == [200, 125] + + tmpConf.guiScale = 1.0 + assert tmpConf.setViewPanePos([400, 250]) + assert tmpConf.getViewPanePos() == [400, 250] + assert tmpConf.viewPanePos == [400, 250] + + assert tmpConf.setViewPanePos([500, 150]) + + # Outline Pane Splitter + tmpConf.guiScale = 2.0 + assert tmpConf.setOutlinePanePos([400, 250]) + assert tmpConf.getOutlinePanePos() == [400, 250] + assert tmpConf.outlnPanePos == [200, 125] + + tmpConf.guiScale = 1.0 + assert tmpConf.setOutlinePanePos([400, 250]) + assert tmpConf.getOutlinePanePos() == [400, 250] + assert tmpConf.outlnPanePos == [400, 250] + + assert tmpConf.setOutlinePanePos([500, 150]) + + # Getters Only + # ============ + tmpConf.guiScale = 1.0 + assert tmpConf.getTextWidth() == 600 + assert tmpConf.getTextMargin() == 40 + assert tmpConf.getTabWidth() == 40 + assert tmpConf.getFocusWidth() == 800 + + tmpConf.guiScale = 2.0 + assert tmpConf.getTextWidth() == 1200 + assert tmpConf.getTextMargin() == 80 + assert tmpConf.getTabWidth() == 80 + assert tmpConf.getFocusWidth() == 1600 + + # Flag Setters + # ============ + assert not tmpConf.setShowRefPanel(False) + assert not tmpConf.showRefPanel + assert tmpConf.setShowRefPanel(True) + + assert not tmpConf.setViewComments(False) + assert not tmpConf.viewComments + assert tmpConf.setViewComments(True) + + assert not tmpConf.setViewSynopsis(False) + assert not tmpConf.viewSynopsis + assert tmpConf.setViewSynopsis(True) + + # Check Final File + # ================ + + assert tmpConf.confChanged + assert tmpConf.saveConfig() + assert not tmpConf.confChanged + + copyfile(confFile, testFile) + assert cmpFiles(testFile, compFile, [2, 9]) + +# END Test testBaseConfig_SettersGetters + +@pytest.mark.base +def testBaseConfig_Internal(monkeypatch, tmpConf): + """Check internal functions. + """ + # Function _packList + assert tmpConf._packList(["A", 1, 2.0, None, False]) == "A, 1, 2.0, None, False" + + # Function _unpackList + assert tmpConf._unpackList("1, 2, 3", [0, 0, 0], tmpConf.CNF_I_LST) == [1, 2, 3] + assert tmpConf._unpackList("1, 2 ", [0, 0, 0], tmpConf.CNF_I_LST) == [1, 2, 0] + assert tmpConf._unpackList("A, B, C", [0, 0, 0], tmpConf.CNF_I_LST) == [0, 0, 0] + assert tmpConf._unpackList("1, 2, 3", ["X", "Y", "Z"], tmpConf.CNF_S_LST) == ["1", "2", "3"] + assert tmpConf._unpackList("A, B ", ["X", "Y", "Z"], tmpConf.CNF_S_LST) == ["A", "B", "Z"] + assert tmpConf._unpackList("A, B, C", ["X", "Y", "Z"], tmpConf.CNF_S_LST) == ["A", "B", "C"] + assert tmpConf._unpackList("A, B, C", ["X", "Y", "Z"], tmpConf.CNF_STR) == ["X", "Y", "Z"] + + # Function _parseLine + cnfParse = configparser.ConfigParser() + cnfParse.read_string( + "[Main]\n" + "val_string = dummy\n" + "val_int = 123\n" + "val_bool = True\n" + "val_list_string = A, B, C\n" + "val_list_int = 1, 2, 3\n" + ) + + assert tmpConf._parseLine( + cnfParse, "Main", "val_string", tmpConf.CNF_STR, "default" + ) == "dummy" + assert tmpConf._parseLine( + cnfParse, "Main", "nope", tmpConf.CNF_STR, "default" + ) == "default" + + assert tmpConf._parseLine( + cnfParse, "Main", "val_int", tmpConf.CNF_INT, "0" + ) == 123 + assert tmpConf._parseLine( + cnfParse, "Main", "nope", tmpConf.CNF_INT, 0 + ) == 0 + assert tmpConf._parseLine( + cnfParse, "Main", "val_string", tmpConf.CNF_INT, 0 + ) == 0 + + assert tmpConf._parseLine( + cnfParse, "Main", "val_bool", tmpConf.CNF_BOOL, False + ) is True + assert tmpConf._parseLine( + cnfParse, "Main", "nope", tmpConf.CNF_BOOL, False + ) is False + assert tmpConf._parseLine( + cnfParse, "Main", "val_string", tmpConf.CNF_BOOL, False + ) is False + + assert tmpConf._parseLine( + cnfParse, "Main", "val_list_string", tmpConf.CNF_S_LST, ["W", "X", "Y", "Z"] + ) == ["A", "B", "C", "Z"] + assert tmpConf._parseLine( + cnfParse, "Main", "nope", tmpConf.CNF_S_LST, ["W", "X", "Y", "Z"] + ) == ["W", "X", "Y", "Z"] + + assert tmpConf._parseLine( + cnfParse, "Main", "val_list_int", tmpConf.CNF_I_LST, [6, 7, 8, 9] + ) == [1, 2, 3, 9] + assert tmpConf._parseLine( + cnfParse, "Main", "nope", tmpConf.CNF_S_LST, [6, 7, 8, 9] + ) == [6, 7, 8, 9] + + # Function _checkNone + assert tmpConf._checkNone(None) is None + assert tmpConf._checkNone("None") is None + assert tmpConf._checkNone("stuff") == "stuff" + + # Function _checkOptionalPackages + # (Assumes enchant package exists ans is importable) + tmpConf._checkOptionalPackages() + assert tmpConf.hasEnchant is True + + monkeypatch.setitem(sys.modules, "enchant", None) + tmpConf._checkOptionalPackages() + assert tmpConf.hasEnchant is False + monkeypatch.undo() + + monkeypatch.setattr("shutil.which", lambda *args: "dummy") + tmpConf._checkOptionalPackages() + assert tmpConf.hasAssistant is True + monkeypatch.undo() + + monkeypatch.setattr("shutil.which", lambda *args: None) + tmpConf._checkOptionalPackages() + assert tmpConf.hasAssistant is False + monkeypatch.undo() + +# END Test testBaseConfig_Internal diff --git a/tests/test_base_error.py b/tests/test_base_error.py new file mode 100644 index 00000000..472faaa2 --- /dev/null +++ b/tests/test_base_error.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +"""novelWriter Error Tester +""" + +import nw +import pytest + +from PyQt5.QtWidgets import qApp + +from dummy import causeException + +from nw.error import NWErrorMessage, exceptionHandler + +@pytest.mark.base +def testBaseError_Dialog(qtbot, monkeypatch, fncDir, tmpDir): + """Test the error dialog. + """ + qApp.closeAllWindows() + nwGUI = nw.main(["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir]) + qtbot.addWidget(nwGUI) + nwGUI.show() + qtbot.waitForWindowShown(nwGUI) + + nwErr = NWErrorMessage(nwGUI) + qtbot.addWidget(nwErr) + nwErr.show() + + # Invalid Error Message + nwErr.setMessage(Exception, "Faulty Error", 123) + assert nwErr.msgBody.toPlainText() == "Failed to generate error report ..." + + # Valid Error Message + monkeypatch.setattr("PyQt5.QtCore.QSysInfo.kernelVersion", lambda: "1.2.3") + nwErr.setMessage(Exception, "Fine Error", None) + theMessage = nwErr.msgBody.toPlainText() + assert theMessage + assert "Fine Error" in theMessage + assert "Exception" in theMessage + assert "(1.2.3)" in theMessage + monkeypatch.undo() + + # No kernel version retrieved + monkeypatch.setattr("PyQt5.QtCore.QSysInfo.kernelVersion", causeException) + nwErr.setMessage(Exception, "Almost Fine Error", None) + theMessage = nwErr.msgBody.toPlainText() + assert theMessage + assert "(Unknown)" in theMessage + monkeypatch.undo() + + nwErr._doClose() + nwErr.close() + nwGUI.closeMain() + +# END Test testBaseError_Dialog + +@pytest.mark.base +def testBaseError_Handler(qtbot, monkeypatch, fncDir, tmpDir): + """Test the error handler. This test doesn'thave any asserts, but it + checks that the error handler handles potential exceptions. The test + will fail if excpetions are not handled. + """ + qApp.closeAllWindows() + nwGUI = nw.main(["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir]) + qtbot.addWidget(nwGUI) + nwGUI.show() + qtbot.waitForWindowShown(nwGUI) + + # Normal shutdown + monkeypatch.setattr(NWErrorMessage, "exec_", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda *args: None) + exceptionHandler(Exception, "Error Message", None) + monkeypatch.undo() + + # Should not crash when no GUI is found + monkeypatch.setattr(NWErrorMessage, "exec_", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.topLevelWidgets", lambda: []) + exceptionHandler(Exception, "Error Message", None) + monkeypatch.undo() + + # Should handle qApp failing + monkeypatch.setattr(NWErrorMessage, "exec_", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.topLevelWidgets", causeException) + exceptionHandler(Exception, "Error Message", None) + monkeypatch.undo() + + # Should handle failing to close main GUI + monkeypatch.setattr(NWErrorMessage, "exec_", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda *args: None) + monkeypatch.setattr(nwGUI, "closeMain", causeException) + exceptionHandler(Exception, "Error Message", None) + monkeypatch.undo() + + nwGUI.closeMain() + +# END Test testBaseError_Handler diff --git a/tests/test_base_init.py b/tests/test_base_init.py new file mode 100644 index 00000000..b691bfa1 --- /dev/null +++ b/tests/test_base_init.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +"""novelWriter Main Init Tester +""" + +import nw +import pytest +import logging +import sys + +from dummy import DummyMain + +@pytest.mark.base +def testBaseInit_Launch(caplog, monkeypatch, tmpDir): + """Check launching the main GUI. + """ + monkeypatch.setattr("nw.guimain.GuiMain", DummyMain) + + # Testmode launch + nwGUI = nw.main( + ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ) + assert isinstance(nwGUI, DummyMain) + + # Darwin launch + monkeypatch.setitem(sys.modules, "Foundation", None) + osDarwin = nw.CONFIG.osDarwin + nw.CONFIG.osDarwin = True + nwGUI = nw.main( + ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ) + assert isinstance(nwGUI, DummyMain) + assert "Foundation" in caplog.messages[1] + nw.CONFIG.osDarwin = osDarwin + + # Normal launch + monkeypatch.setattr("PyQt5.QtWidgets.QApplication.__init__", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.QApplication.setApplicationName", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.QApplication.setApplicationVersion", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.QApplication.setWindowIcon", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.QApplication.setOrganizationDomain", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.QApplication.exec_", lambda *args: 0) + with pytest.raises(SystemExit) as ex: + nw.main(["--config=%s" % tmpDir, "--data=%s" % tmpDir]) + + assert ex.value.code == 0 + + monkeypatch.undo() + +# END Test testBaseInit_Launch + +@pytest.mark.base +def testBaseInit_Options(monkeypatch, tmpDir): + """Test command line options for logging level. + """ + monkeypatch.setattr("nw.guimain.GuiMain", DummyMain) + monkeypatch.setattr(sys, "argv", [ + "novelWriter.py", "--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir + ]) + + # Defaults w/None Args + nwGUI = nw.main() + assert nw.logger.getEffectiveLevel() == logging.WARNING + assert nw.CONFIG.debugInfo is False + assert nw.CONFIG.showGUI is False + assert nwGUI.closeMain() == "closeMain" + + # Defaults + nwGUI = nw.main( + ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir, "--style=Fusion"] + ) + assert nw.logger.getEffectiveLevel() == logging.WARNING + assert nw.CONFIG.debugInfo is False + assert nwGUI.closeMain() == "closeMain" + + # Log Levels + nwGUI = nw.main( + ["--testmode", "--info", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ) + assert nw.logger.getEffectiveLevel() == logging.INFO + assert nw.CONFIG.debugInfo is False + assert nwGUI.closeMain() == "closeMain" + + nwGUI = nw.main( + ["--testmode", "--debug", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ) + assert nw.logger.getEffectiveLevel() == logging.DEBUG + assert nw.CONFIG.debugInfo is True + assert nwGUI.closeMain() == "closeMain" + + nwGUI = nw.main( + ["--testmode", "--verbose", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ) + assert nw.logger.getEffectiveLevel() == 5 + assert nw.CONFIG.debugInfo is True + assert nwGUI.closeMain() == "closeMain" + + # Help and Version + with pytest.raises(SystemExit) as ex: + nwGUI = nw.main( + ["--testmode", "--help", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ) + assert nwGUI.closeMain() == "closeMain" + assert ex.value.code == 0 + + with pytest.raises(SystemExit) as ex: + nwGUI = nw.main( + ["--testmode", "--version", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ) + assert nwGUI.closeMain() == "closeMain" + assert ex.value.code == 0 + + # Invalid options + with pytest.raises(SystemExit) as ex: + nwGUI = nw.main( + ["--testmode", "--invalid", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ) + assert nwGUI.closeMain() == "closeMain" + assert ex.value.code == 2 + + # Project Path + nwGUI = nw.main( + ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir, "sample/"] + ) + assert nw.CONFIG.cmdOpen == "sample/" + assert nwGUI.closeMain() == "closeMain" + + monkeypatch.undo() + +# END Test testBaseInit_Options + +@pytest.mark.base +def testBaseInit_Imports(caplog, monkeypatch, tmpDir): + """Check import error handling. + """ + monkeypatch.setattr("nw.guimain.GuiMain", DummyMain) + monkeypatch.setattr("PyQt5.QtWidgets.QApplication.__init__", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.QApplication.exec_", lambda *args: 0) + monkeypatch.setattr("PyQt5.QtWidgets.QErrorMessage.__init__", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.QErrorMessage.resize", lambda *args: None) + monkeypatch.setattr("PyQt5.QtWidgets.QErrorMessage.showMessage", lambda *args: None) + monkeypatch.setitem(sys.modules, "lxml", None) + monkeypatch.setattr("sys.hexversion", 0x0) + monkeypatch.setattr("nw.CONFIG.verQtValue", 50000) + monkeypatch.setattr("nw.CONFIG.verPyQtValue", 50000) + + with pytest.raises(SystemExit) as ex: + _ = nw.main( + ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir] + ) + + assert ex.value.code & 4 == 4 # Python version not satisfied + assert ex.value.code & 8 == 8 # Qt version not satisfied + assert ex.value.code & 16 == 16 # PyQt version not satisfied + assert ex.value.code & 32 == 32 # lxml package missing + + assert "At least Python" in caplog.messages[0] + assert "At least Qt5" in caplog.messages[1] + assert "At least PyQt5" in caplog.messages[2] + assert "lxml" in caplog.messages[3] + + monkeypatch.undo() + +# END Test testBaseInit_Imports diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index f9ce767b..00000000 --- a/tests/test_config.py +++ /dev/null @@ -1,165 +0,0 @@ -# -*- coding: utf-8 -*- -"""novelWriter Config Class Tester -""" - -import pytest -import os - -from nwtools import cmpFiles - -@pytest.mark.core -def testConfigCore(tmpConf, nwTemp, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - - assert tmpConf.confPath == nwTemp - assert tmpConf.saveConfig() - assert cmpFiles(testConf, refConf, [2]) - assert not tmpConf.confChanged - - assert tmpConf.loadConfig() - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigSetConfPath(tmpConf, nwTemp): - assert tmpConf.setConfPath(None) - assert not tmpConf.setConfPath(os.path.join("somewhere", "over", "the", "rainbow")) - assert tmpConf.setConfPath(os.path.join(nwTemp, "novelwriter.conf")) - assert tmpConf.confPath == nwTemp - assert tmpConf.confFile == "novelwriter.conf" - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigSetDataPath(tmpConf, nwTemp): - assert tmpConf.setDataPath(None) - assert not tmpConf.setDataPath(os.path.join("somewhere", "over", "the", "rainbow")) - assert tmpConf.setDataPath(nwTemp) - assert tmpConf.dataPath == nwTemp - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigSetWinSize(tmpConf, nwTemp, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - tmpConf.guiScale = 1.0 - - assert tmpConf.confPath == nwTemp - assert tmpConf.setWinSize(1205, 655) - assert not tmpConf.confChanged - assert tmpConf.setWinSize(70, 70) - assert tmpConf.confChanged - assert tmpConf.setWinSize(1200, 650) - assert tmpConf.saveConfig() - - assert cmpFiles(testConf, refConf, [2]) - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigSetTreeColWidths(tmpConf, nwTemp, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - - assert tmpConf.confPath == nwTemp - tmpConf.guiScale = 1.0 - - assert tmpConf.setTreeColWidths([10, 20, 25]) - assert tmpConf.treeColWidth == [10, 20, 25] - assert tmpConf.setTreeColWidths([200, 50, 30]) - - assert tmpConf.setProjColWidths([10, 20, 30]) - assert tmpConf.projColWidth == [10, 20, 30] - assert tmpConf.setProjColWidths([200, 60, 140]) - - assert tmpConf.confChanged - assert tmpConf.saveConfig() - - assert cmpFiles(testConf, refConf, [2]) - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigSetPanePos(tmpConf, nwTemp, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - - assert tmpConf.confPath == nwTemp - - tmpConf.guiScale = 2.0 - assert tmpConf.setMainPanePos([200, 700]) - assert tmpConf.mainPanePos == [100, 350] - assert tmpConf.getMainPanePos() == [200, 700] - - assert tmpConf.setDocPanePos([300, 300]) - assert tmpConf.docPanePos == [150, 150] - assert tmpConf.getDocPanePos() == [300, 300] - - assert tmpConf.setViewPanePos([400, 250]) - assert tmpConf.viewPanePos == [200, 125] - assert tmpConf.getViewPanePos() == [400, 250] - - assert tmpConf.setOutlinePanePos([400, 250]) - assert tmpConf.outlnPanePos == [200, 125] - assert tmpConf.getOutlinePanePos() == [400, 250] - - tmpConf.guiScale = 1.0 - assert tmpConf.setMainPanePos([300, 800]) - assert tmpConf.setDocPanePos([400, 400]) - assert tmpConf.setViewPanePos([500, 150]) - assert tmpConf.setOutlinePanePos([500, 150]) - - assert tmpConf.confChanged - assert tmpConf.saveConfig() - - assert cmpFiles(testConf, refConf, [2]) - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigFlags(tmpConf, nwTemp, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - - assert tmpConf.confPath == nwTemp - - assert not tmpConf.setShowRefPanel(False) - assert tmpConf.setShowRefPanel(True) - - assert not tmpConf.setViewComments(False) - assert not tmpConf.viewComments - assert tmpConf.setViewComments(True) - - assert not tmpConf.setViewSynopsis(False) - assert not tmpConf.viewSynopsis - assert tmpConf.setViewSynopsis(True) - - assert tmpConf.confChanged - assert tmpConf.saveConfig() - - assert cmpFiles(testConf, refConf, [2]) - assert not tmpConf.confChanged - -@pytest.mark.core -def testTextSizes(tmpConf, nwTemp, nwRef): - assert tmpConf.confPath == nwTemp - - tmpConf.guiScale = 2.0 - assert tmpConf.getTextWidth() == 1200 - assert tmpConf.getTextMargin() == 80 - assert tmpConf.getTabWidth() == 80 - assert tmpConf.getFocusWidth() == 1600 - tmpConf.guiScale = 1.0 - - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigErrors(tmpConf): - nonPath = os.path.join("somewhere", "over", "the", "rainbow") - assert tmpConf.initConfig(nonPath, nonPath) - assert tmpConf.hasError - assert not tmpConf.loadConfig() - assert not tmpConf.saveConfig() - assert not tmpConf.loadRecentCache() - assert len(tmpConf.getErrData()) > 0 - -@pytest.mark.core -def testConfigInternals(tmpConf): - assert tmpConf._checkNone(None) is None - assert tmpConf._checkNone("None") is None diff --git a/tests/test_core_document.py b/tests/test_core_document.py new file mode 100644 index 00000000..9e16c66e --- /dev/null +++ b/tests/test_core_document.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +"""novelWriter NWDoc Class Tester +""" + +import os +import pytest + +from dummy import causeOSError + +from nw.core import NWProject, NWDoc +from nw.core.item import NWItem +from nw.constants import nwItemClass, nwItemLayout + +@pytest.mark.core +def testCoreDocument_LoadSave(monkeypatch, dummyGUI, nwMinimal): + """Test loading and saving a document with the NWDoc class. + """ + theProject = NWProject(dummyGUI) + assert theProject.openProject(nwMinimal) + assert theProject.projPath == nwMinimal + + theDoc = NWDoc(theProject, dummyGUI) + sHandle = "8c659a11cd429" + + # Not a valid handle + assert theDoc.openDocument("dummy") is None + + # Non-existent handle + assert theDoc.openDocument("0000000000000") is None + + # Cause open() to fail while loading + def dummyOpen(*args, **kwargs): + raise OSError + + monkeypatch.setattr("builtins.open", dummyOpen) + assert theDoc.openDocument(sHandle) is None + monkeypatch.undo() + + # Load the text + assert theDoc.openDocument(sHandle) == "### New Scene\n\n" + + # Try to open a new (non-existent) file + nHandle = theProject.projTree.findRoot(nwItemClass.NOVEL) + assert nHandle is not None + xHandle = theProject.newFile("New File", nwItemClass.NOVEL, nHandle) + assert theDoc.openDocument(xHandle) == "" + + # Check cached item + assert isinstance(theDoc._theItem, NWItem) + assert theDoc.openDocument(xHandle, isOrphan=True) == "" + assert theDoc._theItem is None + + # Set handle and save again + theText = "### Test File\n\nText ...\n\n" + assert theDoc.openDocument(xHandle) == "" + assert theDoc.saveDocument(theText) + + # Save again to ensure temp file and previous file is handled + assert theDoc.saveDocument(theText) + + # Check file content + docPath = os.path.join(nwMinimal, "content", xHandle+".nwd") + with open(docPath, mode="r", encoding="utf8") as inFile: + assert inFile.read() == ( + "%%~name: New File\n" + f"%%~path: a508bb932959c/{xHandle}\n" + "%%~kind: NOVEL/SCENE\n" + "### Test File\n\n" + "Text ...\n\n" + ) + + # Force no meta data + theDoc._theItem = None + assert theDoc.saveDocument(theText) + + with open(docPath, mode="r", encoding="utf8") as inFile: + assert inFile.read() == theText + + # Cause open() to fail while saving + monkeypatch.setattr("builtins.open", causeOSError) + assert not theDoc.saveDocument(theText) + monkeypatch.undo() + + # Saving with no handle + theDoc.clearDocument() + assert not theDoc.saveDocument(theText) + + # Delete the last document + assert not theDoc.deleteDocument("dummy") + assert os.path.isfile(docPath) + + # Cause the delete to fail + monkeypatch.setattr("os.unlink", causeOSError) + assert not theDoc.deleteDocument(xHandle) + monkeypatch.undo() + + # Make the delete pass + assert theDoc.deleteDocument(xHandle) + assert not os.path.isfile(docPath) + +# END Test testCoreDocument_Load + +@pytest.mark.core +def testCoreDocument_Methods(monkeypatch, dummyGUI, nwMinimal): + """Test other methods of the NWDoc class. + """ + theProject = NWProject(dummyGUI) + assert theProject.openProject(nwMinimal) + assert theProject.projPath == nwMinimal + + theDoc = NWDoc(theProject, dummyGUI) + sHandle = "8c659a11cd429" + docPath = os.path.join(nwMinimal, "content", sHandle+".nwd") + + assert theDoc.openDocument(sHandle) == "### New Scene\n\n" + + # Check location + assert theDoc.getFileLocation() == docPath + + # Check the item + assert theDoc.getCurrentItem() is not None + assert theDoc.getCurrentItem().itemHandle == sHandle + + # Check the meta + theName, theParent, theClass, theLayout = theDoc.getMeta() + assert theName == "New Scene" + assert theParent == "a6d311a93600a" + assert theClass == nwItemClass.NOVEL + assert theLayout == nwItemLayout.SCENE + + # Add meta data garbage + assert theDoc.saveDocument("%%~ stuff\n### Test File\n\nText ...\n\n") + with open(docPath, mode="r", encoding="utf8") as inFile: + assert inFile.read() == ( + "%%~name: New Scene\n" + f"%%~path: a6d311a93600a/{sHandle}\n" + "%%~kind: NOVEL/SCENE\n" + "%%~ stuff\n" + "### Test File\n\n" + "Text ...\n\n" + ) + + assert theDoc.openDocument(sHandle) == "### Test File\n\nText ...\n\n" + +# END Test testCoreDocument_Methods diff --git a/tests/test_index.py b/tests/test_core_index.py similarity index 85% rename from tests/test_index.py rename to tests/test_core_index.py index 9fabae5b..ad87c7f3 100644 --- a/tests/test_index.py +++ b/tests/test_core_index.py @@ -8,28 +8,28 @@ import json from shutil import copyfile -from nwtools import cmpFiles +from tools import cmpFiles from nw.core.project import NWProject from nw.core.index import NWIndex from nw.constants import nwItemClass, nwItemLayout -@pytest.mark.project -def testIndexBuildCheck(monkeypatch, nwLipsum, nwDummy, nwTempProj, nwRef): +@pytest.mark.core +def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, outDir, refDir): """Test core functionality of scaning, saving, loading and checking the index cache file. """ projFile = os.path.join(nwLipsum, "meta", "tagsIndex.json") - testFile = os.path.join(nwTempProj, "1_tagsIndex.json") - refFile = os.path.join(nwRef, "proj", "1_tagsIndex.json") + testFile = os.path.join(outDir, "coreIndex_LoadSave_tagsIndex.json") + compFile = os.path.join(refDir, "coreIndex_LoadSave_tagsIndex.json") - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.openProject(nwLipsum) monkeypatch.setattr("nw.core.index.time", lambda: 123.4) - theIndex = NWIndex(theProject, nwDummy) + theIndex = NWIndex(theProject, dummyGUI) notIndexable = { "b3643d0f92e32": False, # Novel ROOT "45e6b01ca35c1": False, # Chapter One FOLDER @@ -136,17 +136,19 @@ def testIndexBuildCheck(monkeypatch, nwLipsum, nwDummy, nwTempProj, nwRef): assert theProject.closeProject() copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile) + assert cmpFiles(testFile, compFile) -@pytest.mark.project -def testIndexScanThis(nwMinimal, nwDummy): +# END Test testCoreIndex_LoadSave + +@pytest.mark.core +def testCoreIndex_ScanThis(nwMinimal, dummyGUI): """Test the tag scanner function scanThis. """ - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.openProject(nwMinimal) - theIndex = NWIndex(theProject, nwDummy) + theIndex = NWIndex(theProject, dummyGUI) isValid, theBits, thePos = theIndex.scanThis("tag: this, and this") assert not isValid @@ -162,40 +164,42 @@ def testIndexScanThis(nwMinimal, nwDummy): isValid, theBits, thePos = theIndex.scanThis("@a:") assert isValid - assert str(theBits) == "['@a']" - assert str(thePos) == "[0]" + assert theBits == ["@a"] + assert thePos == [0] isValid, theBits, thePos = theIndex.scanThis("@a:b") assert isValid - assert str(theBits) == "['@a', 'b']" - assert str(thePos) == "[0, 3]" + assert theBits == ["@a", "b"] + assert thePos == [0, 3] isValid, theBits, thePos = theIndex.scanThis("@a:b,c,d") assert isValid - assert str(theBits) == "['@a', 'b', 'c', 'd']" - assert str(thePos) == "[0, 3, 5, 7]" + assert theBits == ["@a", "b", "c", "d"] + assert thePos == [0, 3, 5, 7] isValid, theBits, thePos = theIndex.scanThis("@a : b , c , d") assert isValid - assert str(theBits) == "['@a', 'b', 'c', 'd']" - assert str(thePos) == "[0, 5, 9, 13]" + assert theBits == ["@a", "b", "c", "d"] + assert thePos == [0, 5, 9, 13] isValid, theBits, thePos = theIndex.scanThis("@tag: this, and this") assert isValid - assert str(theBits) == "['@tag', 'this', 'and this']" - assert str(thePos) == "[0, 6, 12]" + assert theBits == ["@tag", "this", "and this"] + assert thePos == [0, 6, 12] assert theProject.closeProject() -@pytest.mark.project -def testIndexCheckThese(nwMinimal, nwDummy): +# END Test testCoreIndex_ScanThis + +@pytest.mark.core +def testCoreIndex_CheckThese(nwMinimal, dummyGUI): """Test the tag checker function checkThese. """ - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.openProject(nwMinimal) - theIndex = NWIndex(theProject, nwDummy) + theIndex = NWIndex(theProject, dummyGUI) nHandle = theProject.newFile("Hello", nwItemClass.NOVEL, "a508bb932959c") cHandle = theProject.newFile("Jane", nwItemClass.CHARACTER, "afb3043c7b2b3") nItem = theProject.projTree[nHandle] @@ -209,29 +213,32 @@ def testIndexCheckThese(nwMinimal, nwDummy): "# Hello World!\n" "@pov: Jane" )) - assert str(theIndex.tagIndex) == "{'Jane': [2, '%s', 'CHARACTER', 'T000001']}" % cHandle + assert theIndex.tagIndex == {"Jane": [2, cHandle, "CHARACTER", "T000001"]} assert theIndex.novelIndex[nHandle]["T000001"]["title"] == "Hello World!" - assert str(theIndex.checkThese(["@tag", "Jane"], cItem)) == "[True, True]" - assert str(theIndex.checkThese(["@tag", "John"], cItem)) == "[True, True]" - assert str(theIndex.checkThese(["@tag", "Jane"], nItem)) == "[True, False]" - assert str(theIndex.checkThese(["@tag", "John"], nItem)) == "[True, True]" - assert str(theIndex.checkThese(["@pov", "John"], nItem)) == "[True, False]" - assert str(theIndex.checkThese(["@pov", "Jane"], nItem)) == "[True, True]" - assert str(theIndex.checkThese(["@ pov", "Jane"], nItem)) == "[False, False]" - assert str(theIndex.checkThese(["@what", "Jane"], nItem)) == "[False, False]" + assert theIndex.checkThese([], cItem) == [] + assert theIndex.checkThese(["@tag", "Jane"], cItem) == [True, True] + assert theIndex.checkThese(["@tag", "John"], cItem) == [True, True] + assert theIndex.checkThese(["@tag", "Jane"], nItem) == [True, False] + assert theIndex.checkThese(["@tag", "John"], nItem) == [True, True] + assert theIndex.checkThese(["@pov", "John"], nItem) == [True, False] + assert theIndex.checkThese(["@pov", "Jane"], nItem) == [True, True] + assert theIndex.checkThese(["@ pov", "Jane"], nItem) == [False, False] + assert theIndex.checkThese(["@what", "Jane"], nItem) == [False, False] assert theProject.closeProject() -@pytest.mark.project -def testIndexScanText(nwMinimal, nwDummy): - """Check the index data extraction functions. +# END Test testCoreIndex_CheckThese + +@pytest.mark.core +def testCoreIndex_ScanText(nwMinimal, dummyGUI): + """Check the index text scanner. """ - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.openProject(nwMinimal) - theIndex = NWIndex(theProject, nwDummy) + theIndex = NWIndex(theProject, dummyGUI) # Some items for fail to scan tests dHandle = theProject.newFolder("Folder", nwItemClass.NOVEL, "a508bb932959c") @@ -392,21 +399,23 @@ def testIndexScanText(nwMinimal, nwDummy): "% synopsis: Synopsis One.\n\n" "Paragraph One.\n\n" )) - assert str(theIndex.refIndex[sHandle]["T000001"]["tags"]) == ( - "[[3, '@pov', 'One'], [5, '@char', 'Two']]" + assert theIndex.refIndex[sHandle]["T000001"]["tags"] == ( + [[3, "@pov", "One"], [5, "@char", "Two"]] ) assert theProject.closeProject() -@pytest.mark.project -def testIndexExtractData(nwMinimal, nwDummy): +# END Test testCoreIndex_ScanText + +@pytest.mark.core +def testCoreIndex_ExtractData(nwMinimal, dummyGUI): """Check the index data extraction functions. """ - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.openProject(nwMinimal) - theIndex = NWIndex(theProject, nwDummy) + theIndex = NWIndex(theProject, dummyGUI) nHandle = theProject.newFile("Hello", nwItemClass.NOVEL, "a508bb932959c") cHandle = theProject.newFile("Jane", nwItemClass.CHARACTER, "afb3043c7b2b3") @@ -424,7 +433,13 @@ def testIndexExtractData(nwMinimal, nwDummy): )) # The novel structure should contain the pointer to the novel file header - assert str(theIndex.getNovelStructure()) == "['%s:T000001']" % nHandle + assert theIndex.getNovelStructure() == ["%s:T000001" % nHandle] + + # Check that excluded files can be skipped + theProject.projTree[nHandle].setExported(False) + assert theIndex.getNovelStructure(skipExcluded=False) == ["%s:T000001" % nHandle] + assert theIndex.getNovelStructure(skipExcluded=True) == [] + assert theIndex.getNovelStructure() == [] # The novel file should have the correct counts cC, wC, pC = theIndex.getCounts(nHandle) @@ -443,8 +458,8 @@ def testIndexExtractData(nwMinimal, nwDummy): # The novel file should now refer to Jane as @pov and @char theRefs = theIndex.getReferences(nHandle) - assert str(theRefs["@pov"]) == "['Jane']" - assert str(theRefs["@char"]) == "['Jane']" + assert theRefs["@pov"] == ["Jane"] + assert theRefs["@char"] == ["Jane"] ## # getBackReferenceList @@ -455,7 +470,7 @@ def testIndexExtractData(nwMinimal, nwDummy): # The character file should have a record of the reference from the novel file theRefs = theIndex.getBackReferenceList(cHandle) - assert str(theRefs) == "{'%s': 'T000001'}" % nHandle + assert theRefs == {nHandle: "T000001"} ## # getTagSource @@ -535,3 +550,5 @@ def testIndexExtractData(nwMinimal, nwDummy): assert pC == 2 assert theProject.closeProject() + +# END Test testCoreIndex_ExtractData diff --git a/tests/test_item.py b/tests/test_core_item.py similarity index 65% rename from tests/test_item.py rename to tests/test_core_item.py index 95aa8470..d592f7c0 100644 --- a/tests/test_item.py +++ b/tests/test_core_item.py @@ -6,13 +6,15 @@ import pytest from lxml import etree -from nw.core.project import NWProject, NWItem +from nw.core import NWProject +from nw.core.item import NWItem from nw.constants import nwItemClass, nwItemType, nwItemLayout -@pytest.mark.project -def testItemSettersSimple(nwDummy): - - theProject = NWProject(nwDummy) +@pytest.mark.core +def testCoreItem_Setters(dummyGUI): + """Test all the simple setters for the NWItem class. + """ + theProject = NWProject(dummyGUI) theItem = NWItem(theProject) # Name @@ -20,12 +22,16 @@ def testItemSettersSimple(nwDummy): assert theItem.itemName == "A Name" theItem.setName("\t A Name ") assert theItem.itemName == "A Name" + theItem.setName(123) + assert theItem.itemName == "" # Handle theItem.setHandle(123) assert theItem.itemHandle is None theItem.setHandle("0123456789abcdef") assert theItem.itemHandle is None + theItem.setHandle("0123456789abg") + assert theItem.itemHandle is None theItem.setHandle("0123456789abc") assert theItem.itemHandle == "0123456789abc" @@ -36,6 +42,8 @@ def testItemSettersSimple(nwDummy): assert theItem.itemParent is None theItem.setParent("0123456789abcdef") assert theItem.itemParent is None + theItem.setParent("0123456789abg") + assert theItem.itemParent is None theItem.setParent("0123456789abc") assert theItem.itemParent == "0123456789abc" @@ -59,6 +67,19 @@ def testItemSettersSimple(nwDummy): theItem.setStatus("Main") assert theItem.itemStatus == "Main" + # Importance + theItem.itemClass = nwItemClass.NOVEL + theItem.setStatus("Nonsense") + assert theItem.itemStatus == "New" + theItem.setStatus("New") + assert theItem.itemStatus == "New" + theItem.setStatus("Note") + assert theItem.itemStatus == "Note" + theItem.setStatus("Draft") + assert theItem.itemStatus == "Draft" + theItem.setStatus("Finished") + assert theItem.itemStatus == "Finished" + # Expanded theItem.setExpanded(8) assert not theItem.isExpanded @@ -73,6 +94,20 @@ def testItemSettersSimple(nwDummy): theItem.setExpanded(True) assert theItem.isExpanded + # Exported + theItem.setExported(8) + assert not theItem.isExported + theItem.setExported(None) + assert not theItem.isExported + theItem.setExported("None") + assert not theItem.isExported + theItem.setExported("What?") + assert not theItem.isExported + theItem.setExported("True") + assert theItem.isExported + theItem.setExported(True) + assert theItem.isExported + # CharCount theItem.setCharCount(None) assert theItem.charCount == 0 @@ -105,10 +140,47 @@ def testItemSettersSimple(nwDummy): theItem.setCursorPos(1) assert theItem.cursorPos == 1 -@pytest.mark.project -def testItemClassSetter(nwDummy): + # Initial Count + theItem.setWordCount(234) + theItem.saveInitialCount() + assert theItem.initCount == 234 - theProject = NWProject(nwDummy) +# END Test testCoreItem_Setters + +@pytest.mark.core +def testCoreItem_TypeSetter(dummyGUI): + """Test the setter for all the nwItemType values for the NWItem + class. + """ + theProject = NWProject(dummyGUI) + theItem = NWItem(theProject) + + # Type + theItem.setType(None) + assert theItem.itemType == nwItemType.NO_TYPE + theItem.setType("NONSENSE") + assert theItem.itemType == nwItemType.NO_TYPE + theItem.setType("NO_TYPE") + assert theItem.itemType == nwItemType.NO_TYPE + theItem.setType("ROOT") + assert theItem.itemType == nwItemType.ROOT + theItem.setType("FOLDER") + assert theItem.itemType == nwItemType.FOLDER + theItem.setType("FILE") + assert theItem.itemType == nwItemType.FILE + theItem.setType("TRASH") + assert theItem.itemType == nwItemType.TRASH + theItem.setType(nwItemType.ROOT) + assert theItem.itemType == nwItemType.ROOT + +# END Test testCoreItem_TypeSetter + +@pytest.mark.core +def testCoreItem_ClassSetter(dummyGUI): + """Test the setter for all the nwItemClass values for the NWItem + class. + """ + theProject = NWProject(dummyGUI) theItem = NWItem(theProject) # Class @@ -138,33 +210,17 @@ def testItemClassSetter(nwDummy): assert theItem.itemClass == nwItemClass.ARCHIVE theItem.setClass("TRASH") assert theItem.itemClass == nwItemClass.TRASH + theItem.setClass(nwItemClass.NOVEL) + assert theItem.itemClass == nwItemClass.NOVEL -@pytest.mark.project -def testItemTypeSetter(nwDummy): +# END Test testCoreItem_ClassSetter - theProject = NWProject(nwDummy) - theItem = NWItem(theProject) - - # Type - theItem.setType(None) - assert theItem.itemType == nwItemType.NO_TYPE - theItem.setType("NONSENSE") - assert theItem.itemType == nwItemType.NO_TYPE - theItem.setType("NO_TYPE") - assert theItem.itemType == nwItemType.NO_TYPE - theItem.setType("ROOT") - assert theItem.itemType == nwItemType.ROOT - theItem.setType("FOLDER") - assert theItem.itemType == nwItemType.FOLDER - theItem.setType("FILE") - assert theItem.itemType == nwItemType.FILE - theItem.setType("TRASH") - assert theItem.itemType == nwItemType.TRASH - -@pytest.mark.project -def testItemLayoutSetter(nwDummy): - - theProject = NWProject(nwDummy) +@pytest.mark.core +def testCoreItem_LayoutSetter(dummyGUI): + """Test the setter for all the nwItemLayout values for the NWItem + class. + """ + theProject = NWProject(dummyGUI) theItem = NWItem(theProject) # Layout @@ -190,14 +246,22 @@ def testItemLayoutSetter(nwDummy): assert theItem.itemLayout == nwItemLayout.SCENE theItem.setLayout("NOTE") assert theItem.itemLayout == nwItemLayout.NOTE + theItem.setLayout(nwItemLayout.NOTE) + assert theItem.itemLayout == nwItemLayout.NOTE -@pytest.mark.project -def testItemXMLPackUnpack(nwDummy): +# END Test testCoreItem_LayoutSetter - theProject = NWProject(nwDummy) - theItem = NWItem(theProject) +@pytest.mark.core +def testCoreItem_XMLPackUnpack(dummyGUI): + """Test packing and unpacking XML objects for the NWItem class. + """ + theProject = NWProject(dummyGUI) nwXML = etree.Element("novelWriterXML") + # File + # ==== + + theItem = NWItem(theProject) theItem.setHandle("0123456789abc") theItem.setParent("0123456789abc") theItem.setOrder(1) @@ -206,7 +270,7 @@ def testItemXMLPackUnpack(nwDummy): theItem.setType("FILE") theItem.setStatus("Main") theItem.setLayout("NOTE") - theItem.setExpanded(True) + theItem.setExported(False) theItem.setParaCount(3) theItem.setWordCount(5) theItem.setCharCount(7) @@ -219,17 +283,18 @@ def testItemXMLPackUnpack(nwDummy): b"" b"" b"A NameFILENOVELNew" - b"TrueNOTE7" + b"FalseNOTE7" b"5311" b"" ) # Unpack + theItem = NWItem(theProject) assert theItem.unpackXML(xContent[0]) assert theItem.itemHandle == "0123456789abc" assert theItem.itemParent == "0123456789abc" assert theItem.itemOrder == 1 - assert theItem.isExpanded + assert theItem.isExported is False assert theItem.paraCount == 3 assert theItem.wordCount == 5 assert theItem.charCount == 7 @@ -238,6 +303,52 @@ def testItemXMLPackUnpack(nwDummy): assert theItem.itemType == nwItemType.FILE assert theItem.itemLayout == nwItemLayout.NOTE + # Folder + # ====== + + theItem = NWItem(theProject) + theItem.setHandle("0123456789abc") + theItem.setParent("0123456789abc") + theItem.setOrder(1) + theItem.setName("A Name") + theItem.setClass("NOVEL") + theItem.setType("FOLDER") + theItem.setStatus("Main") + theItem.setLayout("NOTE") + theItem.setExpanded(True) + theItem.setExported(False) + theItem.setParaCount(3) + theItem.setWordCount(5) + theItem.setCharCount(7) + theItem.setCursorPos(11) + + # Pack + xContent = etree.SubElement(nwXML, "content") + theItem.packXML(xContent) + assert etree.tostring(xContent, pretty_print=False, encoding="utf-8") == ( + b"" + b"" + b"A NameFOLDERNOVELNew" + b"True" + b"" + ) + + # Unpack + theItem = NWItem(theProject) + assert theItem.unpackXML(xContent[0]) + assert theItem.itemHandle == "0123456789abc" + assert theItem.itemParent == "0123456789abc" + assert theItem.itemOrder == 1 + assert theItem.isExpanded is True + assert theItem.isExported is True + assert theItem.paraCount == 0 + assert theItem.wordCount == 0 + assert theItem.charCount == 0 + assert theItem.cursorPos == 0 + assert theItem.itemClass == nwItemClass.NOVEL + assert theItem.itemType == nwItemType.FOLDER + assert theItem.itemLayout == nwItemLayout.NO_LAYOUT + # Errors ## Not an Item @@ -268,3 +379,5 @@ def testItemXMLPackUnpack(nwDummy): assert etree.tostring(xDummy, pretty_print=False, encoding="utf-8") == ( b"" ) + +# END Test testCoreItem_XMLPackUnpack diff --git a/tests/test_core_options.py b/tests/test_core_options.py new file mode 100644 index 00000000..963a12c4 --- /dev/null +++ b/tests/test_core_options.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +"""novelWriter OptionState Class Tester +""" + +import os +import json +import pytest + +from dummy import causeOSError + +from nw.core import NWProject +from nw.core.options import OptionState +from nw.constants import nwFiles + +@pytest.mark.core +def testCoreOptions_LoadSave(monkeypatch, dummyGUI, tmpDir): + """Test loading and saving from the OptionState class. + """ + theProject = NWProject(dummyGUI) + theOpts = OptionState(theProject) + + # Write a test file + optFile = os.path.join(tmpDir, nwFiles.OPTS_FILE) + with open(optFile, mode="w+", encoding="utf8") as outFile: + json.dump({ + "GuiBuildNovel": { + "winWidth": 1000, + "winHeight": 700, + "addNovel": True, + "addNotes": False, + "textFont": "Cantarell", + "dummyItem": None, + }, + "DummyGroup": { + "dummyItem": None, + }, + }, outFile) + + # Load and save with no path set + theProject.projMeta = None + assert not theOpts.loadSettings() + assert not theOpts.saveSettings() + + # Set path + theProject.projMeta = tmpDir + assert theProject.projMeta == tmpDir + + # Cause open() to fail + monkeypatch.setattr("builtins.open", causeOSError) + assert not theOpts.loadSettings() + assert not theOpts.saveSettings() + monkeypatch.undo() + + # Load proper + assert theOpts.loadSettings() + + # Check that unwanted items have been removed + assert theOpts.theState == { + "GuiBuildNovel": { + "winWidth": 1000, + "winHeight": 700, + "addNovel": True, + "addNotes": False, + "textFont": "Cantarell", + }, + } + + # Save proper + assert theOpts.saveSettings() + + # Load again to check we get the values back + assert theOpts.loadSettings() + assert theOpts.theState == { + "GuiBuildNovel": { + "winWidth": 1000, + "winHeight": 700, + "addNovel": True, + "addNotes": False, + "textFont": "Cantarell", + }, + } + +# END Test testCoreOptions_LoadSave + +@pytest.mark.core +def testCoreOptions_SetGet(monkeypatch, dummyGUI, tmpDir): + """Test setting and getting values from the OptionState class. + """ + theProject = NWProject(dummyGUI) + theOpts = OptionState(theProject) + + # Set invalid values + assert not theOpts.setValue("DummyGroup", "dummyItem", None) + assert not theOpts.setValue("GuiBuildNovel", "dummyItem", None) + + # Set valid value + assert theOpts.setValue("GuiBuildNovel", "winWidth", 100) + + # Set some values of different types + assert theOpts.setValue("GuiBuildNovel", "winWidth", 100) + assert theOpts.setValue("GuiBuildNovel", "winHeight", 12.34) + assert theOpts.setValue("GuiBuildNovel", "addNovel", True) + assert theOpts.setValue("GuiBuildNovel", "textFont", "Cantarell") + + # Generic get, doesn't check type + assert theOpts.getValue("GuiBuildNovel", "winWidth", None) == 100 + assert theOpts.getValue("GuiBuildNovel", "winHeight", None) == 12.34 + assert theOpts.getValue("GuiBuildNovel", "addNovel", None) is True + assert theOpts.getValue("GuiBuildNovel", "textFont", None) == "Cantarell" + assert theOpts.getValue("GuiBuildNovel", "dummyItem", None) is None + + # Get type-specific + assert theOpts.getString("GuiBuildNovel", "winWidth", None) == "100" + assert theOpts.getString("GuiBuildNovel", "dummyItem", None) is None + assert theOpts.getInt("GuiBuildNovel", "winWidth", None) == 100 + assert theOpts.getInt("GuiBuildNovel", "textFont", None) is None + assert theOpts.getInt("GuiBuildNovel", "dummyItem", None) is None + assert theOpts.getFloat("GuiBuildNovel", "winWidth", None) == 100.0 + assert theOpts.getFloat("GuiBuildNovel", "textFont", None) is None + assert theOpts.getFloat("GuiBuildNovel", "dummyItem", None) is None + assert theOpts.getBool("GuiBuildNovel", "addNovel", None) is True + assert theOpts.getBool("GuiBuildNovel", "dummyItem", None) is None + + # Check integer validators + assert theOpts.validIntRange(5, 0, 9, 3) == 5 + assert theOpts.validIntRange(5, 0, 4, 3) == 3 + assert theOpts.validIntRange(5, 0, 5, 3) == 5 + assert theOpts.validIntRange(0, 0, 5, 3) == 0 + + assert theOpts.validIntTuple(0, (0, 1, 2), 3) == 0 + assert theOpts.validIntTuple(5, (0, 1, 2), 3) == 3 + +# END Test testCoreOptions_SetGet diff --git a/tests/test_core_project.py b/tests/test_core_project.py new file mode 100644 index 00000000..3abefbbc --- /dev/null +++ b/tests/test_core_project.py @@ -0,0 +1,1215 @@ +# -*- coding: utf-8 -*- +"""novelWriter Project Class Tester +""" + +import pytest +import os + +from shutil import copyfile +from zipfile import ZipFile +from lxml import etree + +from tools import cmpFiles, writeFile, readFile +from dummy import causeOSError + +from nw.core.project import NWProject +from nw.constants import nwItemClass, nwItemType, nwItemLayout, nwFiles +from nw.common import formatTimeStamp + +@pytest.mark.core +def testCoreProject_NewMinimal(fncDir, outDir, refDir, tmpDir, dummyGUI): + """Create a new project from a project wizard dictionary. With + default setting, creating a Minimal project. + """ + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_NewMinimal_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_NewMinimal_nwProject.nwx") + + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + + # Setting no data should fail + assert not theProject.newProject({}) + + # Try again with a proper path + assert theProject.newProject({"projPath": fncDir}) + assert theProject.saveProject() + assert theProject.closeProject() + + # Creating the project once more should fail + assert not theProject.newProject({"projPath": fncDir}) + + # Check the new project + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + + # Open again + assert theProject.openProject(projFile) + + # Save and close + assert theProject.saveProject() + assert theProject.closeProject() + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert not theProject.projChanged + + # Open a second time + assert theProject.openProject(projFile) + assert not theProject.openProject(projFile) + assert theProject.openProject(projFile, overrideLock=True) + assert theProject.saveProject() + assert theProject.closeProject() + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + +# END Test testCoreProject_NewMinimal + +@pytest.mark.core +def testCoreProject_NewCustomA(fncDir, outDir, refDir, dummyGUI): + """Create a new project from a project wizard dictionary. + Custom type with chapters and scenes. + """ + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_NewCustomA_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_NewCustomA_nwProject.nwx") + + projData = { + "projName": "Test Custom", + "projTitle": "Test Novel", + "projAuthors": "Jane Doe\nJohn Doh\n", + "projPath": fncDir, + "popSample": False, + "popMinimal": False, + "popCustom": True, + "addRoots": [ + nwItemClass.PLOT, + nwItemClass.CHARACTER, + nwItemClass.WORLD, + nwItemClass.TIMELINE, + nwItemClass.OBJECT, + nwItemClass.ENTITY, + ], + "numChapters": 3, + "numScenes": 3, + "chFolders": True, + } + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + + assert theProject.newProject(projData) + assert theProject.saveProject() + assert theProject.closeProject() + + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + +# END Test testCoreProject_NewCustomA + +@pytest.mark.core +def testCoreProject_NewCustomB(fncDir, outDir, refDir, dummyGUI): + """Create a new project from a project wizard dictionary. + Custom type without chapters, but with scenes. + """ + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_NewCustomB_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_NewCustomB_nwProject.nwx") + + projData = { + "projName": "Test Custom", + "projTitle": "Test Novel", + "projAuthors": "Jane Doe\nJohn Doh\n", + "projPath": fncDir, + "popSample": False, + "popMinimal": False, + "popCustom": True, + "addRoots": [ + nwItemClass.PLOT, + nwItemClass.CHARACTER, + nwItemClass.WORLD, + nwItemClass.TIMELINE, + nwItemClass.OBJECT, + nwItemClass.ENTITY, + ], + "numChapters": 0, + "numScenes": 6, + "chFolders": True, + } + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + + assert theProject.newProject(projData) + assert theProject.saveProject() + assert theProject.closeProject() + + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + +# END Test testCoreProject_NewCustomB + +@pytest.mark.core +def testCoreProject_NewSampleA(fncDir, tmpConf, dummyGUI, tmpDir): + """Check that we can create a new project can be created from the + provided sample project via a zip file. + """ + projData = { + "projName": "Test Sample", + "projTitle": "Test Novel", + "projAuthors": "Jane Doe\nJohn Doh\n", + "projPath": fncDir, + "popSample": True, + "popMinimal": False, + "popCustom": False, + } + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + theProject.mainConf = tmpConf + + # Sample set, but no path + assert not theProject.newProject({"popSample": True}) + + # Force the lookup path for assets to our temp folder + srcSample = os.path.abspath(os.path.join(tmpConf.appRoot, "sample")) + dstSample = os.path.join(tmpDir, "sample.zip") + tmpConf.assetPath = tmpDir + + # Create and open a defective zip file + with open(dstSample, mode="w+") as outFile: + outFile.write("foo") + + assert not theProject.newProject(projData) + os.unlink(dstSample) + + # Create a real zip file, and unpack it + with ZipFile(dstSample, "w") as zipObj: + zipObj.write(os.path.join(srcSample, "nwProject.nwx"), "nwProject.nwx") + for docFile in os.listdir(os.path.join(srcSample, "content")): + srcDoc = os.path.join(srcSample, "content", docFile) + zipObj.write(srcDoc, "content/"+docFile) + + assert theProject.newProject(projData) + assert theProject.openProject(fncDir) + assert theProject.projName == "Sample Project" + assert theProject.saveProject() + assert theProject.closeProject() + os.unlink(dstSample) + +# END Test testCoreProject_NewSampleA + +@pytest.mark.core +def testCoreProject_NewSampleB(monkeypatch, fncDir, tmpConf, dummyGUI, tmpDir): + """Check that we can create a new project can be created from the + provided sample project folder. + """ + projData = { + "projName": "Test Sample", + "projTitle": "Test Novel", + "projAuthors": "Jane Doe\nJohn Doh\n", + "projPath": fncDir, + "popSample": True, + "popMinimal": False, + "popCustom": False, + } + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + theProject.mainConf = tmpConf + + # Make sure we do not pick up the nw/assets/sample.zip file + tmpConf.assetPath = tmpDir + + # Set a fake project file name + monkeypatch.setattr(nwFiles, "PROJ_FILE", "nothing.nwx") + assert not theProject.newProject(projData) + + monkeypatch.setattr(nwFiles, "PROJ_FILE", "nwProject.nwx") + assert theProject.newProject(projData) + assert theProject.openProject(fncDir) + assert theProject.projName == "Sample Project" + assert theProject.saveProject() + assert theProject.closeProject() + + # Misdirect the appRoot path so neither is possible + tmpConf.appRoot = tmpDir + assert not theProject.newProject(projData) + +# END Test testCoreProject_NewSampleB + +@pytest.mark.core +def testCoreProject_NewRoot(fncDir, outDir, refDir, dummyGUI): + """Check that new root folders can be added to the project. + """ + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_NewRoot_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_NewRoot_nwProject.nwx") + + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + + assert theProject.newProject({"projPath": fncDir}) + assert theProject.setProjectPath(fncDir) + assert theProject.saveProject() + assert theProject.closeProject() + assert theProject.openProject(projFile) + + assert isinstance(theProject.newRoot("Novel", nwItemClass.NOVEL), type(None)) + assert isinstance(theProject.newRoot("Plot", nwItemClass.PLOT), type(None)) + assert isinstance(theProject.newRoot("Character", nwItemClass.CHARACTER), type(None)) + assert isinstance(theProject.newRoot("World", nwItemClass.WORLD), type(None)) + assert isinstance(theProject.newRoot("Timeline", nwItemClass.TIMELINE), str) + assert isinstance(theProject.newRoot("Object", nwItemClass.OBJECT), str) + assert isinstance(theProject.newRoot("Custom1", nwItemClass.CUSTOM), str) + assert isinstance(theProject.newRoot("Custom2", nwItemClass.CUSTOM), str) + + assert theProject.projChanged + assert theProject.saveProject() + assert theProject.closeProject() + + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert not theProject.projChanged + +# END Test testCoreProject_NewRoot + +@pytest.mark.core +def testCoreProject_NewFile(fncDir, outDir, refDir, dummyGUI): + """Check that new files can be added to the project. + """ + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_NewFile_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_NewFile_nwProject.nwx") + + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + + assert theProject.newProject({"projPath": fncDir}) + assert theProject.setProjectPath(fncDir) + assert theProject.saveProject() + assert theProject.closeProject() + assert theProject.openProject(projFile) + + assert isinstance(theProject.newFile("Hello", nwItemClass.NOVEL, "31489056e0916"), str) + assert isinstance(theProject.newFile("Jane", nwItemClass.CHARACTER, "71ee45a3c0db9"), str) + assert theProject.projChanged + assert theProject.saveProject() + assert theProject.closeProject() + + copyfile(projFile, testFile) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) + assert not theProject.projChanged + +# END Test testCoreProject_NewFile + +@pytest.mark.core +def testCoreProject_Open(monkeypatch, nwMinimal, dummyGUI): + """Test opening a project. + """ + theProject = NWProject(dummyGUI) + + # Rename the project file to check handling + rName = os.path.join(nwMinimal, nwFiles.PROJ_FILE) + wName = os.path.join(nwMinimal, nwFiles.PROJ_FILE+"_sdfghj") + os.rename(rName, wName) + assert theProject.openProject(nwMinimal) is False + os.rename(wName, rName) + + # Fail on folder structure check + monkeypatch.setattr("os.mkdir", causeOSError) + assert theProject.openProject(nwMinimal) is False + monkeypatch.undo() + + # Fail on lock file + theProject.setProjectPath(nwMinimal) + assert theProject._writeLockFile() + assert theProject.openProject(nwMinimal) is False + + # Fail to read lockfile (which still opens the project) + monkeypatch.setattr("builtins.open", causeOSError) + assert theProject.openProject(nwMinimal) is True + monkeypatch.undo() + assert theProject.closeProject() + + # Force open with lockfile + theProject.setProjectPath(nwMinimal) + assert theProject._writeLockFile() + assert theProject.openProject(nwMinimal, overrideLock=True) is True + assert theProject.closeProject() + + # Make a junk XML file + oName = os.path.join(nwMinimal, nwFiles.PROJ_FILE[:-3]+"orig") + bName = os.path.join(nwMinimal, nwFiles.PROJ_FILE[:-3]+"bak") + os.rename(rName, oName) + writeFile(rName, "dummy") + assert theProject.openProject(nwMinimal) is False + + # Also write a jun XML backup file + writeFile(bName, "dummy") + assert theProject.openProject(nwMinimal) is False + + # Wrong root item + writeFile(rName, "\n") + assert theProject.openProject(nwMinimal) is False + + # Wrong file version + writeFile(rName, ( + "\n" + "\n" + "\n" + )) + dummyGUI.askResponse = False + assert theProject.openProject(nwMinimal) is False + dummyGUI.undo() + + # Future file version + writeFile(rName, ( + "\n" + "\n" + "\n" + )) + assert theProject.openProject(nwMinimal) is False + + # Larger hex version + writeFile(rName, ( + "\n" + "\n" + "\n" + )) + dummyGUI.askResponse = False + assert theProject.openProject(nwMinimal) is False + dummyGUI.undo() + + # Test skipping XML entries + writeFile(rName, ( + "\n" + "\n" + "\n" + "\n" + "\n" + )) + assert theProject.openProject(nwMinimal) is True + assert theProject.closeProject() + + # Test deprecated XML entries + writeFile(rName, ( + "\n" + "\n" + "\n" + "\n" + "B\n" + "\n" + "\n" + "\n" + )) + assert theProject.openProject(nwMinimal) is True + assert theProject.autoReplace == {"A": "B"} + assert theProject.closeProject() + + # Clean up XML files + os.unlink(rName) + os.unlink(bName) + os.rename(oName, rName) + + # Add some legacy stuff that cannot be removed + writeFile(os.path.join(nwMinimal, "junk"), "dummy") + os.mkdir(os.path.join(nwMinimal, "data_0")) + writeFile(os.path.join(nwMinimal, "data_0", "junk"), "dummy") + dummyGUI.clear() + assert theProject.openProject(nwMinimal) is True + assert "data_0" in dummyGUI.lastAlert + assert theProject.closeProject() + +# END Test testCoreProject_Open + +@pytest.mark.core +def testCoreProject_Save(monkeypatch, nwMinimal, dummyGUI, refDir): + """Test saving a project. + """ + theProject = NWProject(dummyGUI) + testFile = os.path.join(nwMinimal, "nwProject.nwx") + compFile = os.path.join(refDir, os.path.pardir, "minimal", "nwProject.nwx") + + # Nothing to save + assert theProject.saveProject() is False + + # Open test project + assert theProject.openProject(nwMinimal) + + # Fail on folder structure check + monkeypatch.setattr("os.path.isdir", lambda *args: False) + assert theProject.saveProject() is False + monkeypatch.undo() + + # Fail on open file + monkeypatch.setattr("builtins.open", causeOSError) + assert theProject.saveProject() is False + monkeypatch.undo() + + # Successful save + saveCount = theProject.saveCount + autoCount = theProject.autoCount + assert theProject.saveProject() is True + assert theProject.saveCount == saveCount + 1 + assert theProject.autoCount == autoCount + assert cmpFiles(testFile, compFile, [2, 6, 7, 8, 9]) + + # Successful autosave + saveCount = theProject.saveCount + autoCount = theProject.autoCount + assert theProject.saveProject(autoSave=True) is True + assert theProject.saveCount == saveCount + assert theProject.autoCount == autoCount + 1 + assert cmpFiles(testFile, compFile, [2, 6, 7, 8, 9]) + + # Close test project + assert theProject.closeProject() + +# END Test testCoreProject_Save + +@pytest.mark.core +def testCoreProject_LockFile(monkeypatch, fncDir, dummyGUI): + """Test lock file functions for the project folder. + """ + theProject = NWProject(dummyGUI) + + lockFile = os.path.join(fncDir, nwFiles.PROJ_LOCK) + + # No project + assert theProject._writeLockFile() is False + assert theProject._readLockFile() == ["ERROR"] + assert theProject._clearLockFile() is False + + theProject.projPath = fncDir + theProject.mainConf.hostName = "TestHost" + theProject.mainConf.osType = "TestOS" + theProject.mainConf.kernelVer = "1.0" + + # Block open + monkeypatch.setattr("builtins.open", causeOSError) + assert theProject._writeLockFile() is False + monkeypatch.undo() + + # Write lock file + monkeypatch.setattr("nw.core.project.time", lambda: 123.4) + assert theProject._writeLockFile() is True + monkeypatch.undo() + assert readFile(lockFile) == "TestHost\nTestOS\n1.0\n123\n" + + # Block open + monkeypatch.setattr("builtins.open", causeOSError) + assert theProject._readLockFile() == ["ERROR"] + monkeypatch.undo() + + # Read lock file + assert theProject._readLockFile() == ["TestHost", "TestOS", "1.0", "123"] + + # Block unlink + monkeypatch.setattr("os.unlink", causeOSError) + assert os.path.isfile(lockFile) + assert theProject._clearLockFile() is False + assert os.path.isfile(lockFile) + monkeypatch.undo() + + # Clear file + assert os.path.isfile(lockFile) + assert theProject._clearLockFile() is True + assert not os.path.isfile(lockFile) + + # Read again, no file + assert theProject._readLockFile() == [] + + # Read an invalid lock file + writeFile(lockFile, "A\nB") + assert theProject._readLockFile() == ["ERROR"] + assert theProject._clearLockFile() is True + +# END Test testCoreProject_LockFile + +@pytest.mark.core +def testCoreProject_Helpers(monkeypatch, fncDir, dummyGUI): + """Test helper functions for the project folder. + """ + theProject = NWProject(dummyGUI) + + # No path + assert theProject.ensureFolderStructure() is False + + # Set the correct dir + theProject.projPath = fncDir + + # Block user's home folder + monkeypatch.setattr("os.path.expanduser", lambda *args, **kwargs: fncDir) + assert theProject.ensureFolderStructure() is False + monkeypatch.undo() + + # Create a file to block meta folder + metaDir = os.path.join(fncDir, "meta") + writeFile(metaDir, "dummy") + assert theProject.ensureFolderStructure() is False + os.unlink(metaDir) + + # Create a file to block cache folder + cacheDir = os.path.join(fncDir, "cache") + writeFile(cacheDir, "dummy") + assert theProject.ensureFolderStructure() is False + os.unlink(cacheDir) + + # Create a file to block content folder + contentDir = os.path.join(fncDir, "content") + writeFile(contentDir, "dummy") + assert theProject.ensureFolderStructure() is False + os.unlink(contentDir) + + # Now, do it right + assert theProject.ensureFolderStructure() is True + assert os.path.isdir(metaDir) + assert os.path.isdir(cacheDir) + assert os.path.isdir(contentDir) + +# END Test testCoreProject_Helpers + +@pytest.mark.core +def testCoreProject_AccessItems(nwMinimal, dummyGUI): + """Test helper functions for the project folder. + """ + theProject = NWProject(dummyGUI) + theProject.openProject(nwMinimal) + + # Move Novel ROOT to after its files + oldOrder = [ + "a508bb932959c", # ROOT: Novel + "a35baf2e93843", # FILE: Title Page + "a6d311a93600a", # FOLDER: New Chapter + "f5ab3e30151e1", # FILE: New Chapter + "8c659a11cd429", # FILE: New Scene + "7695ce551d265", # ROOT: Plot + "afb3043c7b2b3", # ROOT: Characters + "9d5247ab588e0", # ROOT: World + ] + newOrder = [ + "a35baf2e93843", # FILE: Title Page + "f5ab3e30151e1", # FILE: New Chapter + "8c659a11cd429", # FILE: New Scene + "a6d311a93600a", # FOLDER: New Chapter + "a508bb932959c", # ROOT: Novel + "7695ce551d265", # ROOT: Plot + "afb3043c7b2b3", # ROOT: Characters + "9d5247ab588e0", # ROOT: World + ] + assert theProject.projTree.handles() == oldOrder + assert theProject.setTreeOrder(newOrder) + assert theProject.projTree.handles() == newOrder + + # Add a non-existing item + theProject.projTree._treeOrder.append("01234567789abc") + + # Add an item with a non-existent parent + nHandle = theProject.newFile("Test File", nwItemClass.NOVEL, "a6d311a93600a") + theProject.projTree[nHandle].setParent("cba9876543210") + assert theProject.projTree[nHandle].itemParent == "cba9876543210" + + retOrder = [] + for tItem in theProject.getProjectItems(): + retOrder.append(tItem.itemHandle) + + assert retOrder == [ + "a508bb932959c", # ROOT: Novel + "7695ce551d265", # ROOT: Plot + "afb3043c7b2b3", # ROOT: Characters + "9d5247ab588e0", # ROOT: World + nHandle, # FILE: Test File + "a35baf2e93843", # FILE: Title Page + "a6d311a93600a", # FOLDER: New Chapter + "f5ab3e30151e1", # FILE: New Chapter + "8c659a11cd429", # FILE: New Scene + ] + assert theProject.projTree[nHandle].itemParent is None + +# END Test testCoreProject_AccessItems + +@pytest.mark.core +def testCoreProject_Methods(monkeypatch, nwMinimal, dummyGUI, tmpDir): + """Test other project class methods and functions. + """ + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + assert theProject.openProject(nwMinimal) + assert theProject.projPath == nwMinimal + + # Setting project path + assert theProject.setProjectPath(None) + assert theProject.projPath is None + assert theProject.setProjectPath("") + assert theProject.projPath is None + assert theProject.setProjectPath("~") + assert theProject.projPath == os.path.expanduser("~") + + # Create a new folder and populate it + projPath = os.path.join(nwMinimal, "dummy1") + assert theProject.setProjectPath(projPath, newProject=True) + + # Make os.mkdir fail + monkeypatch.setattr("os.mkdir", causeOSError) + projPath = os.path.join(nwMinimal, "dummy2") + assert not theProject.setProjectPath(projPath, newProject=True) + + # Set back + assert theProject.setProjectPath(nwMinimal) + + # Project Name + assert theProject.setProjectName(" A Name ") + assert theProject.projName == "A Name" + + # Project Title + assert theProject.setBookTitle(" A Title ") + assert theProject.bookTitle == "A Title" + + # Project Authors + assert not theProject.setBookAuthors([]) + assert theProject.setBookAuthors(" Jane Doe \n John Doh \n ") + assert theProject.bookAuthors == ["Jane Doe", "John Doh"] + + # Trash folder + # Should create on first call, and just returned on later calls + assert theProject.projTree["73475cb40a568"] is None + assert theProject.trashFolder() == "73475cb40a568" + assert theProject.trashFolder() == "73475cb40a568" + + # Project backup + assert theProject.doBackup is True + assert theProject.setProjBackup(False) + assert theProject.doBackup is False + + assert not theProject.setProjBackup(True) + theProject.mainConf.backupPath = tmpDir + assert theProject.setProjBackup(True) + + assert theProject.setProjectName("") + assert not theProject.setProjBackup(True) + assert theProject.setProjectName("A Name") + assert theProject.setProjBackup(True) + + # Spell check + theProject.projChanged = False + assert theProject.setSpellCheck(True) + assert not theProject.setSpellCheck(False) + assert theProject.projChanged + + # Spell language + theProject.projChanged = False + assert theProject.setSpellLang(None) + assert theProject.projLang is None + assert theProject.setSpellLang("None") + assert theProject.projLang is None + assert theProject.setSpellLang("en_GB") + assert theProject.projLang == "en_GB" + assert theProject.projChanged + + # Automatic outline update + theProject.projChanged = False + assert theProject.setAutoOutline(True) + assert not theProject.setAutoOutline(False) + assert theProject.projChanged + + # Last edited + theProject.projChanged = False + assert theProject.setLastEdited("0123456789abc") + assert theProject.lastEdited == "0123456789abc" + assert theProject.projChanged + + # Last viewed + theProject.projChanged = False + assert theProject.setLastViewed("0123456789abc") + assert theProject.lastViewed == "0123456789abc" + assert theProject.projChanged + + # Autoreplace + theProject.projChanged = False + assert theProject.setAutoReplace({"A": "B", "C": "D"}) + assert theProject.autoReplace == {"A": "B", "C": "D"} + assert theProject.projChanged + + # Change project tree order + oldOrder = [ + "a508bb932959c", "a35baf2e93843", "a6d311a93600a", + "f5ab3e30151e1", "8c659a11cd429", "7695ce551d265", + "afb3043c7b2b3", "9d5247ab588e0", "73475cb40a568", + ] + newOrder = [ + "f5ab3e30151e1", "8c659a11cd429", "7695ce551d265", + "a508bb932959c", "a35baf2e93843", "a6d311a93600a", + "afb3043c7b2b3", "9d5247ab588e0", + ] + assert theProject.projTree.handles() == oldOrder + assert theProject.setTreeOrder(newOrder) + assert theProject.projTree.handles() == newOrder + assert theProject.setTreeOrder(oldOrder) + assert theProject.projTree.handles() == oldOrder + + # Change status + theProject.projTree["a35baf2e93843"].setStatus("Finished") + theProject.projTree["a6d311a93600a"].setStatus("Draft") + theProject.projTree["f5ab3e30151e1"].setStatus("Note") + theProject.projTree["8c659a11cd429"].setStatus("Finished") + newList = [ + ("New", 1, 1, 1, "New"), + ("Draft", 2, 2, 2, "Note"), # These are swapped + ("Note", 3, 3, 3, "Draft"), # These are swapped + ("Edited", 4, 4, 4, "Finished"), # Renamed + ("Finished", 5, 5, 5, None), # New, with reused name + ] + assert theProject.setStatusColours(newList) + assert theProject.statusItems._theLabels == [ + "New", "Draft", "Note", "Edited", "Finished" + ] + assert theProject.statusItems._theColours == [ + (1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4), (5, 5, 5) + ] + assert theProject.projTree["a35baf2e93843"].itemStatus == "Edited" # Renamed + assert theProject.projTree["a6d311a93600a"].itemStatus == "Note" # Swapped + assert theProject.projTree["f5ab3e30151e1"].itemStatus == "Draft" # Swapped + assert theProject.projTree["8c659a11cd429"].itemStatus == "Edited" # Renamed + + # Change importance + fHandle = theProject.newFile("Jane Doe", nwItemClass.CHARACTER, "afb3043c7b2b3") + theProject.projTree[fHandle].setStatus("Main") + newList = [ + ("New", 1, 1, 1, "New"), + ("Minor", 2, 2, 2, "Minor"), + ("Major", 3, 3, 3, "Major"), + ("Min", 4, 4, 4, "Main"), + ("Max", 5, 5, 5, None), + ] + assert theProject.setImportColours(newList) + assert theProject.importItems._theLabels == [ + "New", "Minor", "Major", "Min", "Max" + ] + assert theProject.importItems._theColours == [ + (1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4), (5, 5, 5) + ] + assert theProject.projTree[fHandle].itemStatus == "Min" + + # Check status counts + assert theProject.statusItems._theCounts == [0, 0, 0, 0, 0] + assert theProject.importItems._theCounts == [0, 0, 0, 0, 0] + theProject.countStatus() + assert theProject.statusItems._theCounts == [1, 1, 1, 2, 0] + assert theProject.importItems._theCounts == [3, 0, 0, 1, 0] + + # Check word counts + theProject.currWCount = 200 + theProject.lastWCount = 100 + assert theProject.getSessionWordCount() == 100 + + # Session stats + monkeypatch.setattr("os.path.isdir", lambda *args, **kwargs: False) + assert not theProject._appendSessionStats() + monkeypatch.undo() + + # Block open + monkeypatch.setattr("builtins.open", causeOSError) + assert not theProject._appendSessionStats() + monkeypatch.undo() + + # Write entry + assert theProject.projMeta == os.path.join(nwMinimal, "meta") + statsFile = os.path.join(theProject.projMeta, nwFiles.SESS_STATS) + + theProject.projOpened = 1600002000 + theProject.novelWCount = 200 + theProject.notesWCount = 100 + + monkeypatch.setattr("nw.core.project.time", lambda: 1600005600) + assert theProject._appendSessionStats() + monkeypatch.undo() + + assert readFile(statsFile) == ( + "# Offset 100\n" + "# Start Time End Time Novel Notes\n" + "%s %s 200 100\n" + ) % (formatTimeStamp(1600002000), formatTimeStamp(1600005600)) + + # Pack XML Value + xElem = etree.Element("element") + theProject._packProjectValue(xElem, "A", "B", allowNone=False) + assert etree.tostring(xElem, pretty_print=False, encoding="utf-8") == ( + b"B" + ) + + xElem = etree.Element("element") + theProject._packProjectValue(xElem, "A", "", allowNone=False) + assert etree.tostring(xElem, pretty_print=False, encoding="utf-8") == ( + b"" + ) + + # Pack XML Key/Value + xElem = etree.Element("element") + theProject._packProjectKeyValue(xElem, "item", {"A": "B", "C": "D"}) + assert etree.tostring(xElem, pretty_print=False, encoding="utf-8") == ( + b"" + b"" + b"B" + b"D" + b"" + b"" + ) + +# END Test testCoreProject_Methods + +@pytest.mark.core +def testCoreProject_OrphanedFiles(dummyGUI, nwLipsum): + """Check that files in the content folder that are not tracked in + the project XML file are handled correctly by the orphaned files + function. It should also restore as much meta data as possible from + the meta line at the top of the document file. + """ + theProject = NWProject(dummyGUI) + + assert theProject.openProject(nwLipsum) + assert theProject.projTree["636b6aa9b697b"] is None + assert theProject.closeProject() + + # First Item with Meta Data + orphPath = os.path.join(nwLipsum, "content", "636b6aa9b697b.nwd") + with open(orphPath, mode="w", encoding="utf8") as outFile: + outFile.write("%%~name:Mars\n") + outFile.write("%%~path:5eaea4e8cdee8/636b6aa9b697b\n") + outFile.write("%%~kind:WORLD/NOTE\n") + outFile.write("%%~invalid\n") + outFile.write("\n") + + # Second Item without Meta Data + orphPath = os.path.join(nwLipsum, "content", "736b6aa9b697b.nwd") + with open(orphPath, mode="w", encoding="utf8") as outFile: + outFile.write("\n") + + # Invalid File Name + dummyPath = os.path.join(nwLipsum, "content", "636b6aa9b697b.txt") + with open(dummyPath, mode="w", encoding="utf8") as outFile: + outFile.write("\n") + + # Invalid File Name + dummyPath = os.path.join(nwLipsum, "content", "636b6aa9b697bb.nwd") + with open(dummyPath, mode="w", encoding="utf8") as outFile: + outFile.write("\n") + + # Invalid File Name + dummyPath = os.path.join(nwLipsum, "content", "abcdefghijklm.nwd") + with open(dummyPath, mode="w", encoding="utf8") as outFile: + outFile.write("\n") + + assert theProject.openProject(nwLipsum) + assert theProject.projPath is not None + assert theProject.projTree["636b6aa9b697bb"] is None + assert theProject.projTree["abcdefghijklm"] is None + + # First Item with Meta Data + oItem = theProject.projTree["636b6aa9b697b"] + assert oItem is not None + assert oItem.itemName == "Mars" + assert oItem.itemHandle == "636b6aa9b697b" + assert oItem.itemParent is None + assert oItem.itemClass == nwItemClass.WORLD + assert oItem.itemType == nwItemType.FILE + assert oItem.itemLayout == nwItemLayout.NOTE + + # Second Item without Meta Data + oItem = theProject.projTree["736b6aa9b697b"] + assert oItem is not None + assert oItem.itemName == "Orphaned File 1" + assert oItem.itemHandle == "736b6aa9b697b" + assert oItem.itemParent is None + assert oItem.itemClass == nwItemClass.NO_CLASS + assert oItem.itemType == nwItemType.FILE + assert oItem.itemLayout == nwItemLayout.NO_LAYOUT + + assert theProject.saveProject(nwLipsum) + assert theProject.closeProject() + + # Finally, check that the orphaned files function returns + # if no project is open and no path is set + assert not theProject._scanProjectFolder() + +# END Test testCoreProject_OrphanedFiles + +@pytest.mark.core +def testCoreProject_OldFormat(dummyGUI, nwOldProj): + """Test that a project folder structure of version 1.0 can be + converted to the latest folder structure. Version 1.0 split the + documents into 'data_0' ... 'data_f' folders, which are now all + contained in a single 'content' folder. + """ + theProject = NWProject(dummyGUI) + + # Create dummy files for known legacy files + deleteFiles = [ + os.path.join(nwOldProj, "cache", "nwProject.nwx.0"), + os.path.join(nwOldProj, "cache", "nwProject.nwx.1"), + os.path.join(nwOldProj, "cache", "nwProject.nwx.2"), + os.path.join(nwOldProj, "cache", "nwProject.nwx.3"), + os.path.join(nwOldProj, "cache", "nwProject.nwx.4"), + os.path.join(nwOldProj, "cache", "nwProject.nwx.5"), + os.path.join(nwOldProj, "cache", "nwProject.nwx.6"), + os.path.join(nwOldProj, "cache", "nwProject.nwx.7"), + os.path.join(nwOldProj, "cache", "nwProject.nwx.8"), + os.path.join(nwOldProj, "cache", "nwProject.nwx.9"), + os.path.join(nwOldProj, "meta", "mainOptions.json"), + os.path.join(nwOldProj, "meta", "exportOptions.json"), + os.path.join(nwOldProj, "meta", "outlineOptions.json"), + os.path.join(nwOldProj, "meta", "timelineOptions.json"), + os.path.join(nwOldProj, "meta", "docMergeOptions.json"), + os.path.join(nwOldProj, "meta", "sessionLogOptions.json"), + ] + + # Add some files that shouldn't be there + deleteFiles.append(os.path.join(nwOldProj, "data_f", "whatnow.nwd")) + deleteFiles.append(os.path.join(nwOldProj, "data_f", "whatnow.txt")) + + # Add some folders that shouldn't be there + os.mkdir(os.path.join(nwOldProj, "stuff")) + os.mkdir(os.path.join(nwOldProj, "data_1", "stuff")) + + # Create dummy files + os.mkdir(os.path.join(nwOldProj, "cache")) + for aFile in deleteFiles: + writeFile(aFile, "Hi") + for aFile in deleteFiles: + assert os.path.isfile(aFile) + + # Open project and check that files that are not supposed to be + # there have been removed + assert theProject.openProject(nwOldProj) + for aFile in deleteFiles: + assert not os.path.isfile(aFile) + + assert not os.path.isdir(os.path.join(nwOldProj, "data_1", "stuff")) + assert not os.path.isdir(os.path.join(nwOldProj, "data_1")) + assert not os.path.isdir(os.path.join(nwOldProj, "data_7")) + assert not os.path.isdir(os.path.join(nwOldProj, "data_8")) + assert not os.path.isdir(os.path.join(nwOldProj, "data_9")) + assert not os.path.isdir(os.path.join(nwOldProj, "data_a")) + assert not os.path.isdir(os.path.join(nwOldProj, "data_f")) + + # Check stuff that has been moved + assert os.path.isdir(os.path.join(nwOldProj, "junk")) + assert os.path.isdir(os.path.join(nwOldProj, "junk", "stuff")) + assert os.path.isfile(os.path.join(nwOldProj, "junk", "whatnow.nwd")) + assert os.path.isfile(os.path.join(nwOldProj, "junk", "whatnow.txt")) + + # Check that files we want to keep are in the right place + assert os.path.isdir(os.path.join(nwOldProj, "cache")) + assert os.path.isdir(os.path.join(nwOldProj, "content")) + assert os.path.isdir(os.path.join(nwOldProj, "meta")) + + assert os.path.isfile(os.path.join(nwOldProj, "content", "f528d831f5b24.nwd")) + assert os.path.isfile(os.path.join(nwOldProj, "content", "88124a4292d8b.nwd")) + assert os.path.isfile(os.path.join(nwOldProj, "content", "91239bf2f8b69.nwd")) + assert os.path.isfile(os.path.join(nwOldProj, "content", "19752e7f9d8af.nwd")) + assert os.path.isfile(os.path.join(nwOldProj, "content", "a764d5acf5a21.nwd")) + assert os.path.isfile(os.path.join(nwOldProj, "content", "9058ae29f0dfd.nwd")) + assert os.path.isfile(os.path.join(nwOldProj, "content", "7ff63b8afc4cd.nwd")) + + assert os.path.isfile(os.path.join(nwOldProj, "meta", "tagsIndex.json")) + assert os.path.isfile(os.path.join(nwOldProj, "meta", "sessionInfo.log")) + + # Close the project + theProject.closeProject() + + # Check that new files have been created + assert os.path.isfile(os.path.join(nwOldProj, "meta", "guiOptions.json")) + assert os.path.isfile(os.path.join(nwOldProj, "meta", "sessionStats.log")) + assert os.path.isfile(os.path.join(nwOldProj, "ToC.txt")) + +# END Test testCoreProject_OldFormat + +@pytest.mark.core +def testCoreProject_LegacyData(monkeypatch, dummyGUI, fncDir): + """Test the functins that handle legacy data folders and structure + with additional tests of failure handling. + """ + theProject = NWProject(dummyGUI) + theProject.setProjectPath(fncDir) + + # assert theProject.newProject({"projPath": fncDir}) + # assert theProject.saveProject() + # assert theProject.closeProject() + + # Check behaviour of deprecated files function on OSError + tstFile = os.path.join(fncDir, "ToC.json") + writeFile(tstFile, "dummy") + assert os.path.isfile(tstFile) + + monkeypatch.setattr("os.unlink", causeOSError) + assert not theProject._deprecatedFiles() + monkeypatch.undo() + + assert theProject._deprecatedFiles() + assert not os.path.isfile(tstFile) + + # Check processing non-folders + tstFile = os.path.join(fncDir, "data_0") + writeFile(tstFile, "dummy") + assert os.path.isfile(tstFile) + + errList = [] + errList = theProject._legacyDataFolder(tstFile, errList) + assert len(errList) > 0 + + # Move folder in data folder, shouldn't be there + tstData = os.path.join(fncDir, "data_1") + errItem = os.path.join(fncDir, "data_1", "stuff") + os.mkdir(tstData) + os.mkdir(errItem) + assert os.path.isdir(tstData) + assert os.path.isdir(errItem) + + # This causes a failure to create the 'junk' folder + monkeypatch.setattr("os.mkdir", causeOSError) + errList = [] + errList = theProject._legacyDataFolder(tstData, errList) + assert len(errList) > 0 + monkeypatch.undo() + + # This causes a failure to move 'stuff' to 'junk' + monkeypatch.setattr("os.rename", causeOSError) + errList = [] + errList = theProject._legacyDataFolder(tstData, errList) + assert len(errList) > 0 + monkeypatch.undo() + + # This should be successful + errList = [] + errList = theProject._legacyDataFolder(tstData, errList) + assert len(errList) == 0 + assert os.path.isdir(os.path.join(fncDir, "junk", "stuff")) + + # Check renaming/deleting of old document files + tstData = os.path.join(fncDir, "data_2") + tstDoc1m = os.path.join(tstData, "000000000001_main.nwd") + tstDoc1b = os.path.join(tstData, "000000000001_main.bak") + tstDoc2m = os.path.join(tstData, "000000000002_main.nwd") + tstDoc2b = os.path.join(tstData, "000000000002_main.bak") + tstDoc3m = os.path.join(tstData, "tooshort003_main.nwd") + tstDoc3b = os.path.join(tstData, "tooshort003_main.bak") + + os.mkdir(tstData) + writeFile(tstDoc1m, "dummy") + writeFile(tstDoc1b, "dummy") + writeFile(tstDoc2m, "dummy") + writeFile(tstDoc2b, "dummy") + writeFile(tstDoc3m, "dummy") + writeFile(tstDoc3b, "dummy") + + # Make the above fail + monkeypatch.setattr("os.rename", causeOSError) + monkeypatch.setattr("os.unlink", causeOSError) + errList = [] + errList = theProject._legacyDataFolder(tstData, errList) + assert len(errList) > 0 + assert os.path.isfile(tstDoc1m) + assert os.path.isfile(tstDoc1b) + assert os.path.isfile(tstDoc2m) + assert os.path.isfile(tstDoc2b) + assert os.path.isfile(tstDoc3m) + assert os.path.isfile(tstDoc3b) + monkeypatch.undo() + + # And succeed ... + errList = [] + errList = theProject._legacyDataFolder(tstData, errList) + assert len(errList) == 0 + + assert not os.path.isdir(tstData) + assert os.path.isfile(os.path.join(fncDir, "content", "2000000000001.nwd")) + assert os.path.isfile(os.path.join(fncDir, "content", "2000000000002.nwd")) + assert os.path.isfile(os.path.join(fncDir, "junk", "tooshort003_main.nwd")) + assert os.path.isfile(os.path.join(fncDir, "junk", "tooshort003_main.bak")) + +# END Test testCoreProject_LegacyData + +@pytest.mark.core +def testCoreProject_Backup(monkeypatch, dummyGUI, nwMinimal, tmpDir): + """Test the automated backup feature of the project class. The test + creates a backup of the Minimal test project, and then unzips the + backupd file and checks that the project XML file is identical to + the original file. + """ + theProject = NWProject(dummyGUI) + assert theProject.openProject(nwMinimal) + + # Test faulty settings + + # No project + dummyGUI.hasProject = False + assert not theProject.zipIt(doNotify=False) + dummyGUI.hasProject = True + + # Invalid path + theProject.mainConf.backupPath = None + assert not theProject.zipIt(doNotify=False) + + # Missing project name + theProject.mainConf.backupPath = tmpDir + theProject.projName = "" + assert not theProject.zipIt(doNotify=False) + + # Non-existent folder + theProject.mainConf.backupPath = os.path.join(tmpDir, "nonexistent") + theProject.projName = "Test Minimal" + assert not theProject.zipIt(doNotify=False) + + # Same folder as project (causes infinite loop in zipping) + theProject.mainConf.backupPath = nwMinimal + assert not theProject.zipIt(doNotify=False) + + # Set a valid folder + theProject.mainConf.backupPath = tmpDir + + # Can't make folder + monkeypatch.setattr("os.mkdir", causeOSError) + assert not theProject.zipIt(doNotify=False) + monkeypatch.undo() + + # Can't write archive + monkeypatch.setattr("shutil.make_archive", causeOSError) + assert not theProject.zipIt(doNotify=False) + monkeypatch.undo() + + # Test correct settings + assert theProject.zipIt(doNotify=True) + + theFiles = os.listdir(os.path.join(tmpDir, "Test Minimal")) + assert len(theFiles) == 1 + + theZip = theFiles[0] + assert theZip[:12] == "Backup from " + assert theZip[-4:] == ".zip" + + # Extract the archive + with ZipFile(os.path.join(tmpDir, "Test Minimal", theZip), "r") as inZip: + inZip.extractall(os.path.join(tmpDir, "extract")) + + # Check that the main project file was restored + assert cmpFiles( + os.path.join(nwMinimal, "nwProject.nwx"), + os.path.join(tmpDir, "extract", "nwProject.nwx") + ) + +# END Test testCoreProject_Backup diff --git a/tests/test_core_spell.py b/tests/test_core_spell.py new file mode 100644 index 00000000..1ef94816 --- /dev/null +++ b/tests/test_core_spell.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""novelWriter Spell Check Class Tester +""" + +import os +import sys +import pytest + +from dummy import causeOSError +from tools import readFile, writeFile + +from nw.core.spellcheck import NWSpellCheck, NWSpellEnchant, NWSpellSimple +from nw.constants import nwConst + +@pytest.mark.core +def testCoreSpell_Super(monkeypatch, tmpDir, tmpConf): + """Test the spell checker super class + """ + wList = os.path.join(tmpDir, "wordlist.txt") + writeFile(wList, "a_word\nb_word\nc_word\n") + + spChk = NWSpellCheck() + spChk.mainConf = tmpConf + + # Check that dummy functions return results that reflects that spell + # checking is effectively disabled + assert spChk.setLanguage("", "") is None + assert spChk.checkWord("") + assert spChk.suggestWords("") == [] + assert spChk.listDictionaries() == [] + assert spChk.describeDict() == ("", "") + + # Check language info + assert NWSpellCheck.expandLanguage("en") == "English" + assert NWSpellCheck.expandLanguage("en_GB") == "English (GB)" + + # Add a word to the user's dictionary + assert spChk._readProjectDictionary("dummy") is False + monkeypatch.setattr("builtins.open", causeOSError) + assert spChk._readProjectDictionary(wList) is False + monkeypatch.undo() + assert spChk._readProjectDictionary(wList) is True + assert spChk.projectDict == wList + + # Cannot write to file + monkeypatch.setattr("builtins.open", causeOSError) + assert spChk.addWord("d_word") is False + monkeypatch.undo() + assert readFile(wList) == "a_word\nb_word\nc_word\n" + + # First time, OK + assert spChk.addWord("d_word") is True + assert readFile(wList) == "a_word\nb_word\nc_word\nd_word\n" + + # But not added twice + assert spChk.addWord("d_word") is False + assert readFile(wList) == "a_word\nb_word\nc_word\nd_word\n" + +# END Test testCoreSpell_Super + +@pytest.mark.core +def testCoreSpell_Enchant(monkeypatch, tmpDir, tmpConf): + """Test the pyenchant spell checker + """ + wList = os.path.join(tmpDir, "wordlist.txt") + writeFile(wList, "a_word\nb_word\nc_word\n") + + # Block the enchant package (and trigger the dummy class) + monkeypatch.setitem(sys.modules, "enchant", None) + spChk = NWSpellEnchant() + + spChk.setLanguage("en", wList) + assert spChk.setLanguage("", "") is None + assert spChk.checkWord("") + assert spChk.suggestWords("") == [] + assert spChk.listDictionaries() == [] + assert spChk.describeDict() == ("", "") + + monkeypatch.undo() + + # Load the proper enchant package + spChk = NWSpellEnchant() + spChk.mainConf = tmpConf + spChk.setLanguage("en", wList) + + assert spChk.checkWord("a_word") + assert spChk.checkWord("b_word") + assert spChk.checkWord("c_word") + assert not spChk.checkWord("d_word") + + spChk.addWord("d_word") + assert spChk.checkWord("d_word") + + wSuggest = spChk.suggestWords("wrod") + assert len(wSuggest) > 0 + assert "word" in wSuggest + + dList = spChk.listDictionaries() + assert len(dList) > 0 + + aTag, aName = spChk.describeDict() + assert aTag == "en" + assert aName != "" + +# END Test testCoreSpell_Enchant + +@pytest.mark.core +def testCoreSpell_Simple(monkeypatch, tmpDir, tmpConf): + """Test the fallback simple spell checker + """ + wList = os.path.join(tmpDir, "wordlist.txt") + wDict = os.path.join(tmpDir, "en.dict") + writeFile(wList, "a_word\nb_word\nc_word\n") + writeFile(wDict, "# Comment\ne_word\nf_word\ng_word\n") + + spChk = NWSpellSimple() + spChk.mainConf = tmpConf + spChk.mainConf.dictPath = tmpDir + + # Load dictionary, but fail + monkeypatch.setattr("builtins.open", causeOSError) + spChk.setLanguage("en", wList) + assert spChk.spellLanguage is None + assert spChk.WORDS == spChk.projDict + monkeypatch.undo() + + # Load dictionary properly + spChk.setLanguage("en", wList) + assert spChk.projDict == ["a_word", "b_word", "c_word"] + assert spChk.WORDS == ["e_word", "f_word", "g_word", "a_word", "b_word", "c_word"] + + # Check words + assert spChk.checkWord("a_word") + assert spChk.checkWord("b_word") + assert spChk.checkWord("c_word") + assert not spChk.checkWord("d_word") + assert spChk.checkWord("e_word") + assert spChk.checkWord("f_word") + assert spChk.checkWord("g_word") + + # Add word + spChk.addWord("d_word") + assert spChk.checkWord("d_word") + + # Check spelling + assert spChk.suggestWords(" \t") == [] + + wSuggest = spChk.suggestWords("d_wrod") + assert len(wSuggest) > 0 + assert "d_word" in wSuggest + + # Break the matching + monkeypatch.setattr("difflib.get_close_matches", lambda *args, **kwargs: [""]) + assert spChk.suggestWords("word") == [] + monkeypatch.undo() + + # Capitalisation + wSuggest = spChk.suggestWords("D_wrod") + assert len(wSuggest) > 0 + assert "D_word" in wSuggest + + # List dictionaries + assert spChk.listDictionaries() == [("en", "English [%s]" % nwConst.SP_INTERNAL)] + + # Description + aTag, aName = spChk.describeDict() + assert aTag == "en" + assert aName == nwConst.SP_INTERNAL + +# END Test testCoreSpell_Simple diff --git a/tests/test_core_status.py b/tests/test_core_status.py new file mode 100644 index 00000000..515b4b47 --- /dev/null +++ b/tests/test_core_status.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""novelWriter Status Class Tester +""" + +import pytest + +from lxml import etree + +from nw.core.status import NWStatus + +@pytest.mark.core +def testCoreStatus_Entries(): + """Test all the simple setters for the NWItem class. + """ + theStatus = NWStatus() + + # Add entries + theStatus.addEntry("New", (100, 100, 100)) + theStatus.addEntry("Minor", (200, 50, 0)) + theStatus.addEntry("Major", (200, 150, 0)) + theStatus.addEntry("Main", (50, 200, 0)) + + assert theStatus._theLabels == ["New", "Minor", "Major", "Main"] + assert theStatus._theColours == [(100, 100, 100), (200, 50, 0), (200, 150, 0), (50, 200, 0)] + assert theStatus._theCounts == [0, 0, 0, 0] + assert theStatus._theMap["New"] == 0 + assert theStatus._theMap["Minor"] == 1 + assert theStatus._theMap["Major"] == 2 + assert theStatus._theMap["Main"] == 3 + assert theStatus._theLength == 4 + + # Lookups + assert theStatus.lookupEntry(None) is None + assert theStatus.lookupEntry("dummy") is None + assert theStatus.lookupEntry("Main") == 3 + + # Checks + assert theStatus.checkEntry(123) == "New" + assert theStatus.checkEntry("Stuff") == "New" + assert theStatus.checkEntry("New ") == "New" + assert theStatus.checkEntry(" Main ") == "Main" + + # Set new list + newList = [ + ("New", 1, 1, 1, "New"), + ("Minor", 2, 2, 2, "Minor"), + ("Major", 3, 3, 3, "Major"), + ("Min", 4, 4, 4, "Main"), + ("Max", 5, 5, 5, None), + ] + assert theStatus.setNewEntries(None) == {} + assert theStatus.setNewEntries(newList) == {"Main": "Min"} + + assert theStatus._theLabels == ["New", "Minor", "Major", "Min", "Max"] + assert theStatus._theColours == [(1, 1, 1), (2, 2, 2), (3, 3, 3), (4, 4, 4), (5, 5, 5)] + assert theStatus._theCounts == [0, 0, 0, 0, 0] + assert theStatus._theMap["New"] == 0 + assert theStatus._theMap["Minor"] == 1 + assert theStatus._theMap["Major"] == 2 + assert theStatus._theMap["Min"] == 3 + assert theStatus._theMap["Max"] == 4 + assert theStatus._theLength == 5 + + # Add counts + countTo = [3, 5, 7, 9, 11] + for i, n in enumerate(countTo): + for _ in range(n): + theStatus.countEntry(theStatus._theLabels[i]) + assert theStatus._theCounts == countTo + + # Iterate + for i, (sA, sB, sC) in enumerate(theStatus): + assert sA == theStatus._theLabels[i] + assert sB == theStatus._theColours[i] + assert sC == theStatus._theCounts[i] + + assert theStatus[9] == (None, None, None) + + # Clear counts + theStatus.resetCounts() + assert theStatus._theCounts == [0, 0, 0, 0, 0] + +# END Test testCoreStatus_Entries + +@pytest.mark.core +def testCoreStatus_XMLPackUnpack(): + """Test all the simple setters for the NWItem class. + """ + theStatus = NWStatus() + theStatus.addEntry("New", (100, 100, 100)) + theStatus.addEntry("Minor", (200, 50, 0)) + theStatus.addEntry("Major", (200, 150, 0)) + theStatus.addEntry("Main", (50, 200, 0)) + + countTo = [3, 5, 7, 9] + for i, n in enumerate(countTo): + for _ in range(n): + theStatus.countEntry(theStatus._theLabels[i]) + + nwXML = etree.Element("novelWriterXML") + + # Pack + xStatus = etree.SubElement(nwXML, "status") + theStatus.packXML(xStatus) + assert etree.tostring(xStatus, pretty_print=False, encoding="utf-8") == ( + b"" + b"New" + b"Minor" + b"Major" + b"Main" + b"" + ) + + # Unpack + theStatus = NWStatus() + assert theStatus.unpackXML(xStatus) + assert theStatus._theLabels == ["New", "Minor", "Major", "Main"] + assert theStatus._theColours == [(100, 100, 100), (200, 50, 0), (200, 150, 0), (50, 200, 0)] + assert theStatus._theCounts == [0, 0, 0, 0] + assert theStatus._theMap["New"] == 0 + assert theStatus._theMap["Minor"] == 1 + assert theStatus._theMap["Major"] == 2 + assert theStatus._theMap["Main"] == 3 + assert theStatus._theLength == 4 + +# END Test testCoreStatus_XMLPackUnpack diff --git a/tests/test_core_tohtml.py b/tests/test_core_tohtml.py new file mode 100644 index 00000000..7c228edf --- /dev/null +++ b/tests/test_core_tohtml.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- +"""novelWriter ToHtml Class Tester +""" + +import pytest + +from nw.core import NWProject, NWIndex, ToHtml + +@pytest.mark.core +def testCoreToHtml_Format(dummyGUI): + """Test all the formatters for the ToHtml class. + """ + theProject = NWProject(dummyGUI) + dummyGUI.theIndex = NWIndex(theProject, dummyGUI) + theHtml = ToHtml(theProject, dummyGUI) + + # Export Mode + # =========== + + assert theHtml._formatSynopsis("synopsis text") == ( + "

Synopsis: synopsis text

\n" + ) + assert theHtml._formatComments("comment text") == ( + "

Comment: comment text

\n" + ) + + assert theHtml._formatKeywords("") == "" + assert theHtml._formatKeywords("tag: Jane") == ( + "
Tag: Jane
" + ) + assert theHtml._formatKeywords("char: Bod, Jane") == ( + "
" + "Characters: " + "Bod, " + "Jane" + "
" + ) + + # Preview Mode + # ============ + + theHtml.setPreview(True, True) + + assert theHtml._formatSynopsis("synopsis text") == ( + "

Synopsis: synopsis text

\n" + ) + assert theHtml._formatComments("comment text") == ( + "

comment text

\n" + ) + + assert theHtml._formatKeywords("") == "" + assert theHtml._formatKeywords("tag: Jane") == ( + "
Tag: Jane
" + ) + assert theHtml._formatKeywords("char: Bod, Jane") == ( + "
" + "Characters: " + "Bod, " + "Jane" + "
" + ) + +# END Test testCoreToHtml_Format + +@pytest.mark.core +def testCoreToHtml_Convert(dummyGUI): + """Test the converter of the ToHtml class. + """ + theProject = NWProject(dummyGUI) + dummyGUI.theIndex = NWIndex(theProject, dummyGUI) + theHtml = ToHtml(theProject, dummyGUI) + + # Export Mode + # =========== + + theHtml.isNovel = True + + # Header 1 + theHtml.theText = "# Title\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "

Title

\n" + + # Header 2 + theHtml.theText = "## Chapter Title\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "

Chapter Title

\n" + + # Header 3 + theHtml.theText = "### Scene Title\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "

Scene Title

\n" + + # Header 4 + theHtml.theText = "#### Section Title\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "

Section Title

\n" + + theHtml.isNovel = False + theHtml.setLinkHeaders(True) + + # Header 1 + theHtml.theText = "# Heading One\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "

Heading One

\n" + + # Header 2 + theHtml.theText = "## Heading Two\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "

Heading Two

\n" + + # Header 3 + theHtml.theText = "### Heading Three\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "

Heading Three

\n" + + # Header 4 + theHtml.theText = "#### Heading Four\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "

Heading Four

\n" + + # Text + theHtml.theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == ( + "

Some nested bold and italic and " + "strikethrough text here

\n" + ) + + # Text w/Hard Break + theHtml.theText = "Line one \nLine two \nLine three\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == ( + "

Line one
Line two
Line three

\n" + ) + + # Synopsis + theHtml.theText = "%synopsis: The synopsis ...\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "" + + theHtml.setSynopsis(True) + theHtml.theText = "%synopsis: The synopsis ...\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == ( + "

Synopsis: The synopsis ...

\n" + ) + + # Comment + theHtml.theText = "% A comment ...\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "" + + theHtml.setComments(True) + theHtml.theText = "% A comment ...\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == ( + "

Comment: A comment ...

\n" + ) + + # Keywords + theHtml.theText = "@char: Bod, Jane\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == "" + + theHtml.setKeywords(True) + theHtml.theText = "@char: Bod, Jane\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == ( + "
Characters: " + "Bod, Jane
" + ) + + # Direct Tests + # ============ + + theHtml.isNovel = True + + # Title + theHtml.theTokens = [ + (theHtml.T_TITLE, 1, "A Title", None, theHtml.A_PBB_NO | theHtml.A_CENTRE), + (theHtml.T_EMPTY, 1, "", None, theHtml.A_NONE), + ] + theHtml.doConvert() + assert theHtml.theResult == ( + "

" + "A Title

\n" + ) + + # Separator + theHtml.theTokens = [ + (theHtml.T_SEP, 1, "* * *", None, theHtml.A_CENTRE), + (theHtml.T_EMPTY, 1, "", None, theHtml.A_NONE), + ] + theHtml.doConvert() + assert theHtml.theResult == "

* * *

\n" + + # Skip + theHtml.theTokens = [ + (theHtml.T_SKIP, 1, "", None, theHtml.A_NONE), + (theHtml.T_EMPTY, 1, "", None, theHtml.A_NONE), + ] + theHtml.doConvert() + assert theHtml.theResult == "

 

\n" + + # Styles + # ====== + + theHtml.setLinkHeaders(False) + + # Align Left + theHtml.setStyles(False) + theHtml.theTokens = [ + (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_LEFT), + ] + theHtml.doConvert() + assert theHtml.theResult == ( + "

A Title

\n" + ) + + theHtml.setStyles(True) + + # Align Left + theHtml.theTokens = [ + (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_LEFT), + ] + theHtml.doConvert() + assert theHtml.theResult == ( + "

A Title

\n" + ) + + # Align Right + theHtml.theTokens = [ + (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_RIGHT), + ] + theHtml.doConvert() + assert theHtml.theResult == ( + "

A Title

\n" + ) + + # Align Centre + theHtml.theTokens = [ + (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_CENTRE), + ] + theHtml.doConvert() + assert theHtml.theResult == ( + "

A Title

\n" + ) + + # Align Justify + theHtml.theTokens = [ + (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_JUSTIFY), + ] + theHtml.doConvert() + assert theHtml.theResult == ( + "

A Title

\n" + ) + + # Page Break Always + theHtml.theTokens = [ + (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_PBB | theHtml.A_PBA), + ] + theHtml.doConvert() + assert theHtml.theResult == ( + "

A Title

\n" + ) + + # Page Break Avoid + theHtml.theTokens = [ + (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_PBB_AV | theHtml.A_PBA_AV), + ] + theHtml.doConvert() + assert theHtml.theResult == ( + "

A Title

\n" + ) + + # Page Break ANever + theHtml.theTokens = [ + (theHtml.T_HEAD1, 1, "A Title", None, theHtml.A_PBB_NO | theHtml.A_PBA_NO), + ] + theHtml.doConvert() + assert theHtml.theResult == ( + "

A Title

\n" + ) + + # Preview Mode + # ============ + + theHtml.setPreview(True, True) + + # Text (HTML4) + theHtml.theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == ( + "

Some nested bold and italic and " + "strikethrough " + "text here

\n" + ) + +# END Test testCoreToHtml_Convert + +@pytest.mark.core +def testCoreToHtml_Methods(dummyGUI): + """Test all the other methods of the ToHtml class. + """ + theProject = NWProject(dummyGUI) + theHtml = ToHtml(theProject, dummyGUI) + + # Auto-Replace + docText = "Text with & short–dash, long—dash …\n" + theHtml.theText = docText + theHtml.doAutoReplace() + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theResult == ( + "

Text with <brackets> & short–dash, long—dash …

\n" + ) + + # Revert on MD + assert theHtml.theMarkdown == ( + "Text with <brackets> & short–dash, long—dash …\n\n" + ) + theHtml.doPostProcessing() + assert theHtml.theMarkdown == docText + "\n" + + # With Preview, No Revert + theHtml.setPreview(True, True) + theHtml.theText = docText + theHtml.doAutoReplace() + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theMarkdown == ( + "Text with <brackets> & short–dash, long—dash …\n\n" + ) + theHtml.doPostProcessing() + assert theHtml.theMarkdown == ( + "Text with <brackets> & short–dash, long—dash …\n\n" + ) + + # CSS + # === + + assert len(theHtml.getStyleSheet()) > 1 + assert "p {text-align: left;}" in theHtml.getStyleSheet() + assert "p {text-align: justify;}" not in theHtml.getStyleSheet() + + theHtml.setJustify(True) + assert "p {text-align: left;}" not in theHtml.getStyleSheet() + assert "p {text-align: justify;}" in theHtml.getStyleSheet() + + theHtml.setStyles(False) + assert theHtml.getStyleSheet() == [] + +# END Test testCoreToHtml_Methods diff --git a/tests/test_core_tokenizer.py b/tests/test_core_tokenizer.py new file mode 100644 index 00000000..3d0774b5 --- /dev/null +++ b/tests/test_core_tokenizer.py @@ -0,0 +1,644 @@ +# -*- coding: utf-8 -*- +"""novelWriter Tokenizer Class Tester +""" + +import pytest + +from nw.core import NWProject, NWDoc +from nw.core.tokenizer import Tokenizer + +@pytest.mark.core +def testCoreToken_Setters(dummyGUI): + """Test all the setters for the Tokenizer class. + """ + theProject = NWProject(dummyGUI) + theToken = Tokenizer(theProject, dummyGUI) + + # Verify defaults + assert theToken.fmtTitle == "%title%" + assert theToken.fmtChapter == "%title%" + assert theToken.fmtUnNum == "%title%" + assert theToken.fmtScene == "%title%" + assert theToken.fmtSection == "%title%" + assert theToken.hideScene is False + assert theToken.hideSection is False + assert theToken.linkHeaders is False + assert theToken.doBodyText is True + assert theToken.doSynopsis is False + assert theToken.doComments is False + assert theToken.doKeywords is False + assert theToken.doJustify is False + + # Set new values + theToken.setTitleFormat("T: %title%") + theToken.setChapterFormat("C: %title%") + theToken.setUnNumberedFormat("U: %title%") + theToken.setSceneFormat("S: %title%", True) + theToken.setSectionFormat("X: %title%", True) + theToken.setLinkHeaders(True) + theToken.setBodyText(False) + theToken.setSynopsis(True) + theToken.setComments(True) + theToken.setKeywords(True) + theToken.setJustify(True) + + # Check new values + assert theToken.fmtTitle == "T: %title%" + assert theToken.fmtChapter == "C: %title%" + assert theToken.fmtUnNum == "U: %title%" + assert theToken.fmtScene == "S: %title%" + assert theToken.fmtSection == "X: %title%" + assert theToken.hideScene is True + assert theToken.hideSection is True + assert theToken.linkHeaders is True + assert theToken.doBodyText is False + assert theToken.doSynopsis is True + assert theToken.doComments is True + assert theToken.doKeywords is True + assert theToken.doJustify is True + +# END Test testCoreToken_Setters + +@pytest.mark.core +def testCoreToken_TextOps(monkeypatch, nwMinimal, dummyGUI): + """Test handling files and text in the Tokenizer class. + """ + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + theToken = Tokenizer(theProject, dummyGUI) + + assert theProject.openProject(nwMinimal) + sHandle = "8c659a11cd429" + + # Set some content to work with + + docText = ( + "### Scene Six\n\n" + "This is text with _italic text_, some **bold text**, some ~~deleted text~~, " + "and some **_mixed text_** and **some _nested_ text**.\n\n" + "#### Replace\n\n" + "Also, replace and .\n\n" + ) + docTextR = docText.replace("", "this").replace("", "that") + + nDoc = NWDoc(theProject, dummyGUI) + nDoc.openDocument(sHandle) + nDoc.saveDocument(docText) + nDoc.clearDocument() + + theProject.setAutoReplace({"A": "this", "B": "that"}) + + assert theProject.saveProject() + + # Root heading + assert theToken.addRootHeading("dummy") is False + assert theToken.addRootHeading(sHandle) is False + assert theToken.addRootHeading("7695ce551d265") is True + assert theToken.theMarkdown == "# Notes: Plot\n\n" + + # Set text + assert theToken.setText("dummy") is False + assert theToken.setText(sHandle) is True + assert theToken.theText == docText + + monkeypatch.setattr("nw.constants.nwConst.MAX_DOCSIZE", 100) + assert theToken.setText(sHandle, docText) is True + assert theToken.theText == ( + "# ERROR\n\n" + "Document 'New Scene' is too big (0.00 MB). Skipping.\n\n" + ) + monkeypatch.undo() + + assert theToken.setText(sHandle, docText) is True + assert theToken.theText == docText + + assert theToken.isNone is False + assert theToken.isTitle is False + assert theToken.isBook is False + assert theToken.isPage is False + assert theToken.isPart is False + assert theToken.isUnNum is False + assert theToken.isChap is False + assert theToken.isScene is True + assert theToken.isNote is False + assert theToken.isNovel is True + + # Auto replace + theToken.doAutoReplace() + assert theToken.theText == docTextR + + # Access + assert theToken.getResult() is None + assert theToken.getResultSize() == 0 + theToken.theResult = "" + assert theToken.getResultSize() == 0 + + # Post Processing + theToken.theResult = r"This is text with escapes: \** \~~ \__" + theToken.doPostProcessing() + assert theToken.theResult == "This is text with escapes: ** ~~ __" + +# END Test testCoreToken_TextOps + +@pytest.mark.core +def testCoreToken_Tokenize(dummyGUI): + """Test the tokenization of the Tokenizer class. + """ + theProject = NWProject(dummyGUI) + theToken = Tokenizer(theProject, dummyGUI) + + # Header 1 + theToken.theText = "# Novel Title\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD1, 1, "Novel Title", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "# Novel Title\n\n" + + # Header 2 + theToken.theText = "## Chapter One\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "Chapter One", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "## Chapter One\n\n" + + # Header 3 + theToken.theText = "### Scene One\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD3, 1, "Scene One", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "### Scene One\n\n" + + # Header 4 + theToken.theText = "#### A Section\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD4, 1, "A Section", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "#### A Section\n\n" + + # Comment + theToken.theText = "% A comment\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_COMMENT, 1, "A comment", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "\n" + + theToken.setComments(True) + theToken.tokenizeText() + assert theToken.theMarkdown == "% A comment\n\n" + + # Symopsis + theToken.theText = "%synopsis: The synopsis\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_SYNOPSIS, 1, "The synopsis", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + theToken.theText = "% synopsis: The synopsis\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_SYNOPSIS, 1, "The synopsis", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "\n" + + theToken.setSynopsis(True) + theToken.tokenizeText() + assert theToken.theMarkdown == "% synopsis: The synopsis\n\n" + + # Keyword + theToken.theText = "@char: Bod\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_KEYWORD, 1, "char: Bod", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "\n" + + theToken.setKeywords(True) + theToken.tokenizeText() + assert theToken.theMarkdown == "@char: Bod\n\n" + + # Text + theToken.theText = "Some plain text\non two lines\n\n\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_TEXT, 1, "Some plain text", [], Tokenizer.A_NONE), + (Tokenizer.T_TEXT, 2, "on two lines", [], Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 3, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some plain text\non two lines\n\n\n\n" + + theToken.setBodyText(False) + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 3, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "\n\n\n" + theToken.setBodyText(True) + + # Text Emphasis + theToken.theText = "Some **bolded text** on this lines\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some **bolded text** on this lines", + [ + [5, 2, Tokenizer.FMT_B_B], + [18, 2, Tokenizer.FMT_B_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some **bolded text** on this lines\n\n" + + theToken.theText = "Some _italic text_ on this lines\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some _italic text_ on this lines", + [ + [5, 1, Tokenizer.FMT_I_B], + [17, 1, Tokenizer.FMT_I_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some _italic text_ on this lines\n\n" + + theToken.theText = "Some **_bold italic text_** on this lines\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some **_bold italic text_** on this lines", + [ + [5, 2, Tokenizer.FMT_B_B], + [7, 1, Tokenizer.FMT_I_B], + [24, 1, Tokenizer.FMT_I_E], + [25, 2, Tokenizer.FMT_B_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some **_bold italic text_** on this lines\n\n" + + theToken.theText = "Some ~~strikethrough text~~ on this lines\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some ~~strikethrough text~~ on this lines", + [ + [5, 2, Tokenizer.FMT_D_B], + [25, 2, Tokenizer.FMT_D_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some ~~strikethrough text~~ on this lines\n\n" + + theToken.theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some **nested bold and _italic_ and ~~strikethrough~~ text** here", + [ + [5, 2, Tokenizer.FMT_B_B], + [23, 1, Tokenizer.FMT_I_B], + [30, 1, Tokenizer.FMT_I_E], + [36, 2, Tokenizer.FMT_D_B], + [51, 2, Tokenizer.FMT_D_E], + [58, 2, Tokenizer.FMT_B_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == ( + "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n\n" + ) + + # Check the markdown function as well + assert theToken.getFilteredMarkdown() == ( + "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n\n" + ) + +# END Test testCoreToken_Tokenize + +@pytest.mark.core +def testCoreToken_Headers(dummyGUI): + """Test the header and page parser of the Tokenizer class. + """ + theProject = NWProject(dummyGUI) + theToken = Tokenizer(theProject, dummyGUI) + + # Nothing + theToken.theText = "Some text ...\n" + assert theToken.doHeaders() is True + theToken.isNone = True + assert theToken.doHeaders() is False + theToken.isNone = False + assert theToken.doHeaders() is True + theToken.isNote = True + assert theToken.doHeaders() is False + theToken.isNote = False + + ## + # Novel + ## + + theToken.isNovel = True + + # Titles + # ====== + + # H1: Title + theToken.theText = "# Novel Title\n" + theToken.setTitleFormat(r"T: %title%") + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD1, 1, "T: Novel Title", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # Chapters + # ======== + + # H2: Chapter + theToken.theText = "## Chapter One\n" + theToken.setChapterFormat(r"C: %title%") + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "C: Chapter One", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Unnumbered Chapter + theToken.theText = "## Chapter One\n" + theToken.setUnNumberedFormat(r"U: %title%") + theToken.isUnNum = True + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "U: Chapter One", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Unnumbered Chapter with Star + theToken.theText = "## *Prologue\n" + theToken.setUnNumberedFormat(r"U: %title%") + theToken.isUnNum = False + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "U: Prologue", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Chapter Word Number + theToken.theText = "## Chapter\n" + theToken.setChapterFormat(r"Chapter %chw%") + theToken.numChapter = 0 + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "Chapter One", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Chapter Roman Number Upper Case + theToken.theText = "## Chapter\n" + theToken.setChapterFormat(r"Chapter %chI%") + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "Chapter II", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Chapter Roman Number Lower Case + theToken.theText = "## Chapter\n" + theToken.setChapterFormat(r"Chapter %chi%") + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "Chapter iii", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # Scenes + # ====== + + # H3: Scene w/Title + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"S: %title%", False) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD3, 1, "S: Scene One", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene Hidden wo/Format + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"", True) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene wo/Format, first + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"", False) + theToken.firstScene = True + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene wo/Format, not first + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"", False) + theToken.firstScene = False + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_SKIP, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene Separator, first + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"* * *", False) + theToken.firstScene = True + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene Separator, not first + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"* * *", False) + theToken.firstScene = False + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_SEP, 1, "* * *", None, Tokenizer.A_CENTRE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene w/Absolute Number + theToken.theText = "### A Scene\n" + theToken.setSceneFormat(r"Scene %sca%", False) + theToken.numAbsScene = 0 + theToken.numChScene = 0 + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD3, 1, "Scene 1", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene w/Chapter Number + theToken.theText = "### A Scene\n" + theToken.setSceneFormat(r"Scene %ch%.%sc%", False) + theToken.numAbsScene = 0 + theToken.numChScene = 1 + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD3, 1, "Scene 3.2", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # Sections + # ======== + + # H4: Section Hidden wo/Format + theToken.theText = "#### A Section\n" + theToken.setSectionFormat(r"", True) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H4: Section Visible wo/Format + theToken.theText = "#### A Section\n" + theToken.setSectionFormat(r"", False) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_SKIP, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H4: Section w/Format + theToken.theText = "#### A Section\n" + theToken.setSectionFormat(r"X: %title%", False) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD4, 1, "X: A Section", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H4: Section Separator + theToken.theText = "#### A Section\n" + theToken.setSectionFormat(r"* * *", False) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_SEP, 1, "* * *", None, Tokenizer.A_CENTRE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # Check the first scene detector + assert theToken.firstScene is False + theToken.firstScene = True + assert theToken.firstScene is True + theToken.theText = "Some text ...\n" + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.firstScene is False + + ## + # Title or Partition + ## + + theToken.isNovel = False + + # H1: Title + theToken.theText = "# Novel Title\n" + theToken.tokenizeText() + theToken.isTitle = True + theToken.isPart = False + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_TITLE, 1, "Novel Title", None, Tokenizer.A_PBB_NO | Tokenizer.A_CENTRE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_PBA | Tokenizer.A_CENTRE), + ] + + # H1: Partition + theToken.theText = "# Partition Title\n" + theToken.setTitleFormat(r"T: %title%") + theToken.tokenizeText() + theToken.isTitle = False + theToken.isPart = True + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD1, 1, "Partition Title", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_PBA | Tokenizer.A_CENTRE), + ] + + ## + # Page + ## + + theToken.isNovel = False + theToken.isTitle = False + theToken.isPart = False + theToken.isPage = True + + # Some Page Text + theToken.theText = "Page text\n\nMore text\n" + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_TEXT, 1, "Page text", [], Tokenizer.A_PBB | Tokenizer.A_LEFT), + (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_LEFT), + (Tokenizer.T_TEXT, 3, "More text", [], Tokenizer.A_LEFT), + (Tokenizer.T_EMPTY, 3, "", None, Tokenizer.A_LEFT), + ] + +# END Test testCoreToken_Headers diff --git a/tests/test_tools.py b/tests/test_core_tools.py similarity index 85% rename from tests/test_tools.py rename to tests/test_core_tools.py index 4832aadb..ceb146a4 100644 --- a/tests/test_tools.py +++ b/tests/test_core_tools.py @@ -7,7 +7,7 @@ import pytest from nw.core.tools import countWords, numberToRoman, numberToWord @pytest.mark.core -def testCountWords(): +def testCoreTools_CountWords(): """Test the word counter and the exclusion filers. """ testText = ( @@ -26,15 +26,46 @@ def testCountWords(): "\n" "\n" "The third paragraph.\n" + "\n" + "Dashes\u2013and even longer\u2014dashes." ) cC, wC, pC = countWords(testText) - assert cC == 108 - assert wC == 17 - assert pC == 3 + assert cC == 138 + assert wC == 22 + assert pC == 4 + +# END Test testCoreTools_CountWords @pytest.mark.core -def testNumberWords(): +def testCoreTools_RomanNumbers(): + """Test conversion of integers to Roman numbers. + """ + assert numberToRoman(None, False) == "NAN" + assert numberToRoman(0, False) == "OOR" + assert numberToRoman(1, False) == "I" + assert numberToRoman(2, False) == "II" + assert numberToRoman(3, False) == "III" + assert numberToRoman(4, False) == "IV" + assert numberToRoman(5, False) == "V" + assert numberToRoman(6, False) == "VI" + assert numberToRoman(7, False) == "VII" + assert numberToRoman(8, False) == "VIII" + assert numberToRoman(9, False) == "IX" + assert numberToRoman(10, False) == "X" + assert numberToRoman(14, False) == "XIV" + assert numberToRoman(42, False) == "XLII" + assert numberToRoman(99, False) == "XCIX" + assert numberToRoman(142, False) == "CXLII" + assert numberToRoman(542, False) == "DXLII" + assert numberToRoman(999, False) == "CMXCIX" + assert numberToRoman(2010, False) == "MMX" + assert numberToRoman(999, True) == "cmxcix" + +# END Test testCoreTools_RomanNumbers + +@pytest.mark.core +def testCoreTools_NumberWords(): """Test the conversion of integer to English words. """ assert numberToWord(0, "en") == "Zero" @@ -70,27 +101,9 @@ def testNumberWords(): assert numberToWord(2, "foo") == "Two" assert numberToWord(3, "foo") == "Three" -@pytest.mark.core -def testRomanNumbers(): - """Test conversion of integers to Roman numbers. - """ - assert numberToRoman(None, False) == "NAN" - assert numberToRoman(0, False) == "OOR" - assert numberToRoman(1, False) == "I" - assert numberToRoman(2, False) == "II" - assert numberToRoman(3, False) == "III" - assert numberToRoman(4, False) == "IV" - assert numberToRoman(5, False) == "V" - assert numberToRoman(6, False) == "VI" - assert numberToRoman(7, False) == "VII" - assert numberToRoman(8, False) == "VIII" - assert numberToRoman(9, False) == "IX" - assert numberToRoman(10, False) == "X" - assert numberToRoman(14, False) == "XIV" - assert numberToRoman(42, False) == "XLII" - assert numberToRoman(99, False) == "XCIX" - assert numberToRoman(142, False) == "CXLII" - assert numberToRoman(542, False) == "DXLII" - assert numberToRoman(999, False) == "CMXCIX" - assert numberToRoman(2010, False) == "MMX" - assert numberToRoman(999, True) == "cmxcix" + # Test out of range values + assert numberToWord(12345, "en") == "[Out of Range]" + assert numberToWord(-2345, "en") == "[Negative]" + assert numberToWord("234", "en") == "[NaN]" + +# END Test testCoreTools_NumberWords diff --git a/tests/test_core_tree.py b/tests/test_core_tree.py new file mode 100644 index 00000000..29c1ca95 --- /dev/null +++ b/tests/test_core_tree.py @@ -0,0 +1,433 @@ +# -*- coding: utf-8 -*- +"""novelWriter NWTree Class Tester +""" + +import os +import pytest + +from lxml import etree + +from nw.core.project import NWProject, NWItem, NWTree +from nw.constants import nwItemClass, nwItemType, nwItemLayout, nwFiles + +@pytest.fixture(scope="function") +def dummyItems(dummyGUI): + """Create a list of dummy items. + """ + theProject = NWProject(dummyGUI) + + itemA = NWItem(theProject) + itemA.itemName = "Novel" + itemA.itemType = nwItemType.ROOT + itemA.itemClass = nwItemClass.NOVEL + itemA.isExpanded = True + + itemB = NWItem(theProject) + itemB.itemName = "Act One" + itemB.itemType = nwItemType.FOLDER + itemB.itemClass = nwItemClass.NOVEL + itemB.isExpanded = True + + itemC = NWItem(theProject) + itemC.itemName = "Chapter One" + itemC.itemType = nwItemType.FILE + itemC.itemClass = nwItemClass.NOVEL + itemC.itemLayout = nwItemLayout.CHAPTER + itemC.charCount = 300 + itemC.wordCount = 50 + itemC.paraCount = 2 + + itemD = NWItem(theProject) + itemD.itemName = "Scene One" + itemD.itemType = nwItemType.FILE + itemD.itemClass = nwItemClass.NOVEL + itemD.itemLayout = nwItemLayout.SCENE + itemD.charCount = 3000 + itemD.wordCount = 500 + itemD.paraCount = 20 + + itemE = NWItem(theProject) + itemE.itemName = "Outtakes" + itemE.itemType = nwItemType.ROOT + itemE.itemClass = nwItemClass.ARCHIVE + itemE.isExpanded = False + + itemF = NWItem(theProject) + itemF.itemName = "Trash" + itemF.itemType = nwItemType.TRASH + itemF.itemClass = nwItemClass.TRASH + itemF.isExpanded = False + + itemG = NWItem(theProject) + itemG.itemName = "Characters" + itemG.itemType = nwItemType.ROOT + itemG.itemClass = nwItemClass.CHARACTER + itemG.isExpanded = True + + itemH = NWItem(theProject) + itemH.itemName = "Jane Doe" + itemH.itemType = nwItemType.FILE + itemH.itemClass = nwItemClass.CHARACTER + itemH.itemLayout = nwItemLayout.NOTE + itemH.charCount = 2000 + itemH.wordCount = 400 + itemH.paraCount = 16 + + theItems = [ + ("a000000000001", None, itemA), + ("b000000000001", "a000000000001", itemB), + ("c000000000001", "b000000000001", itemC), + ("c000000000002", "b000000000001", itemD), + ("a000000000002", None, itemE), + ("a000000000003", None, itemF), + ("a000000000004", None, itemG), + ("b000000000002", "a000000000002", itemH), + ] + + return theItems + +@pytest.mark.core +def testCoreTree_BuildTree(dummyGUI, dummyItems): + """Test building a project tree from a list of items. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + theTree.setSeed(42) + assert theTree._handleSeed == 42 + + # Check that tree is empty (calls NWTree.__bool__) + assert not theTree + + # Check for archive and trash folders + assert theTree.trashRoot() is None + assert theTree.archiveRoot() is None + assert not theTree.isTrashRoot("a000000000003") + + aHandles = [] + for tHandle, pHande, nwItem in dummyItems: + aHandles.append(tHandle) + assert theTree.append(tHandle, pHande, nwItem) + + assert theTree._treeChanged + + # Check that tree is not empty (calls __bool__) + assert theTree + + # Check the number of elements (calls __len__) + assert len(theTree) == len(dummyItems) + + # Check that we have the correct handles + assert theTree.handles() == aHandles + + # Check by iterator (calls __iter__, __next__ and __getitem__) + for theItem, theHandle in zip(theTree, aHandles): + assert theItem.itemHandle == theHandle + + # Check that we have the correct archive and trash folders + assert theTree.trashRoot() == "a000000000003" + assert theTree.archiveRoot() == "a000000000002" + assert theTree.isTrashRoot("a000000000003") + + # Try to add another trash folder + itemT = NWItem(theProject) + itemT.itemName = "Trash" + itemT.itemType = nwItemType.TRASH + itemT.itemClass = nwItemClass.TRASH + itemT.isExpanded = False + + assert not theTree.append("1234567890abc", None, itemT) + assert len(theTree) == len(dummyItems) + + # Generate handle automatically + itemT = NWItem(theProject) + itemT.itemName = "New File" + itemT.itemType = nwItemType.FILE + itemT.itemClass = nwItemClass.NOVEL + itemT.itemLayout = nwItemLayout.SCENE + + assert theTree.append(None, None, itemT) + assert len(theTree) == len(dummyItems) + 1 + + theList = theTree.handles() + assert theList[-1] == "73475cb40a568" + + # Try to add existing handle + assert not theTree.append("73475cb40a568", None, itemT) + assert len(theTree) == len(dummyItems) + 1 + + # Delete a non-existing item + del theTree["dummy"] + assert len(theTree) == len(dummyItems) + 1 + + # Delete the last item + del theTree["73475cb40a568"] + assert len(theTree) == len(dummyItems) + assert "73475cb40a568" not in theTree + + # Delete the Novel, Archive and Trash folders + del theTree["a000000000001"] + assert len(theTree) == len(dummyItems) - 1 + assert "a000000000001" not in theTree + + del theTree["a000000000002"] + assert len(theTree) == len(dummyItems) - 2 + assert "a000000000002" not in theTree + assert theTree.archiveRoot() is None + + del theTree["a000000000003"] + assert len(theTree) == len(dummyItems) - 3 + assert "a000000000003" not in theTree + assert theTree.trashRoot() is None + +# END Test testCoreTree_BuildTree + +@pytest.mark.core +def testCoreTree_Methods(dummyGUI, dummyItems): + """Test building a project tree from a list of items. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + for tHandle, pHande, nwItem in dummyItems: + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + + # Root item lookup + theTree._treeRoots.append("dummy") + assert theTree.findRoot(nwItemClass.WORLD) is None + assert theTree.findRoot(nwItemClass.NOVEL) == "a000000000001" + assert theTree.findRoot(nwItemClass.CHARACTER) == "a000000000004" + + # Check for root uniqueness + assert theTree.checkRootUnique(nwItemClass.CUSTOM) + assert theTree.checkRootUnique(nwItemClass.WORLD) + assert not theTree.checkRootUnique(nwItemClass.NOVEL) + assert not theTree.checkRootUnique(nwItemClass.CHARACTER) + + # Find root item of child item + assert theTree.getRootItem("b000000000001").itemHandle == "a000000000001" + assert theTree.getRootItem("c000000000001").itemHandle == "a000000000001" + assert theTree.getRootItem("c000000000002").itemHandle == "a000000000001" + assert theTree.getRootItem("dummy") is None + + # Get item path + assert theTree.getItemPath("dummy") == [] + assert theTree.getItemPath("c000000000001") == [ + "c000000000001", "b000000000001", "a000000000001" + ] + + # Break the folder parent handle + theTree["b000000000001"].itemParent = "dummy" + assert theTree.getItemPath("c000000000001") == [ + "c000000000001", "b000000000001" + ] + + theTree["b000000000001"].itemParent = "a000000000001" + assert theTree.getItemPath("c000000000001") == [ + "c000000000001", "b000000000001", "a000000000001" + ] + + # Change file layout + assert not theTree.setFileItemLayout("dummy", nwItemLayout.UNNUMBERED) + assert not theTree.setFileItemLayout("b000000000001", nwItemLayout.UNNUMBERED) + assert not theTree.setFileItemLayout("c000000000001", "stuff") + assert theTree.setFileItemLayout("c000000000001", nwItemLayout.UNNUMBERED) + assert theTree["c000000000001"].itemLayout == nwItemLayout.UNNUMBERED + +# END Test testCoreTree_Methods + +@pytest.mark.core +def testCoreTree_MakeHandles(monkeypatch, dummyGUI): + """Test generating item handles. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + theTree.setSeed(42) + + tHandle = theTree._makeHandle() + assert tHandle == "73475cb40a568" + + # Add the next in line to the project to foprce duplicate + theTree._projTree["44cb730c42048"] = None + tHandle = theTree._makeHandle() + assert tHandle == "71ee45a3c0db9" + + # Fix the time() function and force a handle collission + theTree.setSeed(None) + monkeypatch.setattr("nw.core.tree.time", lambda: 123.4) + + tHandle = theTree._makeHandle() + theTree._projTree[tHandle] = None + assert tHandle == "5f466d7afa48b" + + tHandle = theTree._makeHandle() + theTree._projTree[tHandle] = None + assert tHandle == "a79acf4c634a7" + + monkeypatch.undo() + +# END Test testCoreTree_MakeHandles + +@pytest.mark.core +def testCoreTree_Stats(dummyGUI, dummyItems): + """Test project stats methods. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + for tHandle, pHande, nwItem in dummyItems: + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + theTree._treeOrder.append("dummy") + + # Count Words + novelWords, noteWords = theTree.sumWords() + assert novelWords == 550 + assert noteWords == 400 + + # Count types + nRoot, nFolder, nFile = theTree.countTypes() + assert nRoot == 3 + assert nFolder == 1 + assert nFile == 3 + +# END Test testCoreTree_Stats + +@pytest.mark.core +def testCoreTree_Reorder(dummyGUI, dummyItems): + """Test changing tree order. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + aHandle = [] + for tHandle, pHande, nwItem in dummyItems: + aHandle.append(tHandle) + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + + bHandle = aHandle.copy() + bHandle[2], bHandle[3] = bHandle[3], bHandle[2] + assert aHandle != bHandle + + assert theTree.handles() == aHandle + theTree.setOrder(bHandle) + assert theTree.handles() == bHandle + + theTree.setOrder(bHandle + ["dummy"]) + assert theTree.handles() == bHandle + + theTree._treeOrder.append("dummy") + theTree.setOrder(bHandle) + assert theTree.handles() == bHandle + +# END Test testCoreTree_Reorder + +@pytest.mark.core +def testCoreTree_XMLPackUnpack(dummyGUI, dummyItems): + """Test changing tree order. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + for tHandle, pHande, nwItem in dummyItems: + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + + nwXML = etree.Element("novelWriterXML") + theTree.packXML(nwXML) + assert etree.tostring(nwXML, pretty_print=False, encoding="utf-8") == ( + b"" + b"" + b"" + b"NovelROOTNOVELNone" + b"True" + b"" + b"Act OneFOLDERNOVELNone" + b"True" + b"" + b"Chapter OneFILENOVELNone" + b"TrueCHAPTER300" + b"5020" + b"" + b"Scene OneFILENOVELNone" + b"TrueSCENE3000" + b"500200" + b"" + b"OuttakesROOTARCHIVENone" + b"False" + b"" + b"TrashTRASHTRASHNone" + b"False" + b"" + b"CharactersROOTCHARACTERNone" + b"True" + b"" + b"Jane DoeFILECHARACTERNone" + b"TrueNOTE2000" + b"400160" + b"" + ) + + theTree.clear() + assert len(theTree) == 0 + assert not theTree.unpackXML(nwXML) + assert theTree.unpackXML(nwXML[0]) + assert len(theTree) == len(dummyItems) + +# END Test testCoreTree_XMLPackUnpack + +@pytest.mark.core +def testCoreTree_ToCFile(monkeypatch, dummyGUI, dummyItems, tmpDir): + """Test writing the ToC.txt file. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + for tHandle, pHande, nwItem in dummyItems: + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + theTree._treeOrder.append("dummy") + + def dummyIsFile(fileName): + """Return True for items that are files in novelWriter and + should thus also be files in the project folder structure. + """ + dItem = theTree[fileName[8:21]] + assert dItem is not None + return dItem.itemType == nwItemType.FILE + + monkeypatch.setattr("os.path.isfile", dummyIsFile) + + theProject.projContent = "content" + theProject.projPath = None + assert not theTree.writeToCFile() + + theProject.projPath = tmpDir + assert theTree.writeToCFile() + + pathA = os.path.join("content", "c000000000001.nwd") + pathB = os.path.join("content", "c000000000002.nwd") + pathC = os.path.join("content", "b000000000002.nwd") + + with open(os.path.join(tmpDir, nwFiles.TOC_TXT), mode="r", encoding="utf8") as inFile: + assert inFile.read() == ( + "\n" + "Table of Contents\n" + "=================\n" + "\n" + "File Name Class Layout Document Label\n" + "-------------------------------------------------------------\n" + f"{pathA} NOVEL CHAPTER Chapter One\n" + f"{pathB} NOVEL SCENE Scene One\n" + f"{pathC} CHARACTER NOTE Jane Doe\n" + ) + +# END Test testCoreTree_ToCFile diff --git a/tests/test_error.py b/tests/test_error.py deleted file mode 100644 index 179efa68..00000000 --- a/tests/test_error.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -"""novelWriter Error Tester -""" - -import nw -import pytest - -from PyQt5.QtWidgets import qApp - -from nw.error import NWErrorMessage, exceptionHandler - -@pytest.mark.error -def testErrorDialog(qtbot, nwFuncTemp, nwTemp): - qApp.closeAllWindows() - nwGUI = nw.main(["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp]) - qtbot.addWidget(nwGUI) - nwGUI.show() - qtbot.waitForWindowShown(nwGUI) - - nwErr = NWErrorMessage(nwGUI) - qtbot.addWidget(nwErr) - nwErr.show() - - # Invalid Error - nwErr.setMessage(Exception, "Faulty Error", 123) - assert nwErr.msgBody.toPlainText() == "Failed to generate error report ..." - - # Valid Error - nwErr.setMessage(Exception, "First Error", None) - theMessage = nwErr.msgBody.toPlainText() - assert theMessage - assert "First Error" in theMessage - assert "Exception" in theMessage - nwErr._doClose() - nwErr.close() - - theMessage = exceptionHandler(Exception, "Second Error", None, testMode=True) - assert theMessage - assert "Second Error" in theMessage - assert "Exception" in theMessage - - nwGUI.closeMain() - - # qtbot.stopForInteraction() diff --git a/tests/test_dialogs.py b/tests/test_gui_dialogs.py similarity index 90% rename from tests/test_dialogs.py rename to tests/test_gui_dialogs.py index 81c1fc89..96f64da4 100644 --- a/tests/test_dialogs.py +++ b/tests/test_gui_dialogs.py @@ -9,7 +9,7 @@ import os import sys from shutil import copyfile -from nwtools import cmpFiles, getGuiItem +from tools import cmpFiles, getGuiItem from PyQt5.QtCore import Qt, QItemSelectionModel from PyQt5.QtWidgets import ( @@ -30,8 +30,8 @@ typeDelay = 1 stepDelay = 20 @pytest.mark.gui -def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) +def testProjectSettings(qtbot, monkeypatch, yesToAll, fncDir, nwTempGUI, refDir, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -43,8 +43,8 @@ def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwR # Create new project nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) - nwGUI.mainConf.backupPath = nwFuncTemp + assert nwGUI.newProject({"projPath": fncDir}) + nwGUI.mainConf.backupPath = fncDir # Get the dialog object monkeypatch.setattr(GuiProjectSettings, "exec_", lambda *args: None) @@ -135,9 +135,9 @@ def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwR qtbot.wait(stepDelay) # Check the files - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "2_nwProject.nwx") - refFile = os.path.join(nwRef, "gui", "2_nwProject.nwx") + refFile = os.path.join(refDir, "gui", "2_nwProject.nwx") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [2, 8, 9, 10]) @@ -145,8 +145,8 @@ def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwR nwGUI.closeMain() @pytest.mark.gui -def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, nwRef, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) +def testItemEditor(qtbot, yesToAll, monkeypatch, fncDir, nwTempGUI, refDir, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -154,7 +154,7 @@ def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, nwRef, n # Create new, save, open project nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) + assert nwGUI.newProject({"projPath": fncDir}) assert nwGUI.openDocument("0e17daca5f3e1") assert nwGUI.treeView.setSelectedHandle("0e17daca5f3e1", doScroll=True) @@ -208,9 +208,9 @@ def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, nwRef, n qtbot.wait(stepDelay) # Check the files - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "3_nwProject.nwx") - refFile = os.path.join(nwRef, "gui", "3_nwProject.nwx") + refFile = os.path.join(refDir, "gui", "3_nwProject.nwx") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) @@ -218,8 +218,8 @@ def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, nwRef, n nwGUI.closeMain() @pytest.mark.gui -def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) +def testWritingStatsExport(qtbot, monkeypatch, yesToAll, fncDir, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -227,13 +227,13 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): # Create new, save, close project nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) + assert nwGUI.newProject({"projPath": fncDir}) qtbot.wait(200) assert nwGUI.saveProject() assert nwGUI.closeProject() qtbot.wait(stepDelay) - sessFile = os.path.join(nwFuncTemp, "meta", nwFiles.SESS_STATS) + sessFile = os.path.join(fncDir, "meta", nwFiles.SESS_STATS) with open(sessFile, mode="w+", encoding="utf-8") as outFile: outFile.write( "# Start Time End Time Novel Notes\n" @@ -244,10 +244,10 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): ) # Open again, and check the stats - assert nwGUI.openProject(nwFuncTemp) + assert nwGUI.openProject(fncDir) qtbot.wait(stepDelay) - nwGUI.mainConf.lastPath = nwFuncTemp + nwGUI.mainConf.lastPath = fncDir nwGUI.mainMenu.aWritingStats.activate(QAction.Trigger) qtbot.waitUntil(lambda: getGuiItem("GuiWritingStats") is not None, timeout=1000) @@ -264,7 +264,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(100) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -282,7 +282,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.loads(inFile.read()) @@ -299,7 +299,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -316,7 +316,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -329,7 +329,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -341,7 +341,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -357,8 +357,8 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): nwGUI.closeMain() @pytest.mark.gui -def testAboutBox(qtbot, monkeypatch, nwFuncTemp, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp]) +def testAboutBox(qtbot, monkeypatch, fncDir, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -374,12 +374,20 @@ def testAboutBox(qtbot, monkeypatch, nwFuncTemp, nwTemp): msgAbout.show() assert msgAbout.pageAbout.document().characterCount() > 100 + assert msgAbout.pageNotes.document().characterCount() > 100 assert msgAbout.pageLicense.document().characterCount() > 100 - msgAbout.mainConf.guiLang = "whatever" + msgAbout.mainConf.assetPath = "whatever" + + msgAbout._fillNotesPage() + assert msgAbout.pageNotes.toPlainText() == "Error loading release notes text ..." + msgAbout._fillLicensePage() assert msgAbout.pageLicense.toPlainText() == "Error loading license text ..." + msgAbout.showReleaseNotes() + assert msgAbout.tabBox.currentWidget() == msgAbout.pageNotes + # Qt About monkeypatch.setattr(QMessageBox, "aboutQt", lambda *args, **kwargs: None) nwGUI.mainMenu.aAboutQt.activate(QAction.Trigger) @@ -389,9 +397,9 @@ def testAboutBox(qtbot, monkeypatch, nwFuncTemp, nwTemp): nwGUI.closeMain() @pytest.mark.gui -def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, nwTemp): +def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, refDir, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -420,13 +428,13 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, nwTemp): projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") testFile = os.path.join(nwTempBuild, "1_LoremIpsum.nwd") - refFile = os.path.join(nwRef, "build", "1_LoremIpsum.nwd") + refFile = os.path.join(refDir, "build", "1_LoremIpsum.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") testFile = os.path.join(nwTempBuild, "1_LoremIpsum.htm") - refFile = os.path.join(nwRef, "build", "1_LoremIpsum.htm") + refFile = os.path.join(refDir, "build", "1_LoremIpsum.htm") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -459,13 +467,13 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, nwTemp): projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") testFile = os.path.join(nwTempBuild, "2_LoremIpsum.nwd") - refFile = os.path.join(nwRef, "build", "2_LoremIpsum.nwd") + refFile = os.path.join(refDir, "build", "2_LoremIpsum.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") testFile = os.path.join(nwTempBuild, "2_LoremIpsum.htm") - refFile = os.path.join(nwRef, "build", "2_LoremIpsum.htm") + refFile = os.path.join(refDir, "build", "2_LoremIpsum.htm") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -479,14 +487,14 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, nwTemp): assert nwBuild._saveDocument(nwBuild.FMT_NWD) projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") testFile = os.path.join(nwTempBuild, "3_LoremIpsum.nwd") - refFile = os.path.join(nwRef, "build", "3_LoremIpsum.nwd") + refFile = os.path.join(refDir, "build", "3_LoremIpsum.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) assert nwBuild._saveDocument(nwBuild.FMT_HTM) projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") testFile = os.path.join(nwTempBuild, "3_LoremIpsum.htm") - refFile = os.path.join(nwRef, "build", "3_LoremIpsum.htm") + refFile = os.path.join(refDir, "build", "3_LoremIpsum.htm") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -513,14 +521,14 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, nwTemp): assert nwBuild._saveDocument(nwBuild.FMT_NWD) projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") testFile = os.path.join(nwTempBuild, "4_LoremIpsum.nwd") - refFile = os.path.join(nwRef, "build", "4_LoremIpsum.nwd") + refFile = os.path.join(refDir, "build", "4_LoremIpsum.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) assert nwBuild._saveDocument(nwBuild.FMT_HTM) projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") testFile = os.path.join(nwTempBuild, "4_LoremIpsum.htm") - refFile = os.path.join(nwRef, "build", "4_LoremIpsum.htm") + refFile = os.path.join(refDir, "build", "4_LoremIpsum.htm") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -528,14 +536,14 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, nwTemp): assert nwBuild._saveDocument(nwBuild.FMT_JSON_H) projFile = os.path.join(nwLipsum, "Lorem Ipsum.json") testFile = os.path.join(nwTempBuild, "4H_LoremIpsum.json") - refFile = os.path.join(nwRef, "build", "4H_LoremIpsum.json") + refFile = os.path.join(refDir, "build", "4H_LoremIpsum.json") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [8]) assert nwBuild._saveDocument(nwBuild.FMT_JSON_M) projFile = os.path.join(nwLipsum, "Lorem Ipsum.json") testFile = os.path.join(nwTempBuild, "4M_LoremIpsum.json") - refFile = os.path.join(nwRef, "build", "4M_LoremIpsum.json") + refFile = os.path.join(refDir, "build", "4M_LoremIpsum.json") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [8]) @@ -581,9 +589,9 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, nwTemp): nwGUI.closeMain() @pytest.mark.gui -def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef, nwTemp): +def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, refDir, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -612,7 +620,7 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef projFile = os.path.join(nwLipsum, "content", "73475cb40a568.nwd") testFile = os.path.join(nwTempGUI, "4_73475cb40a568.nwd") - refFile = os.path.join(nwRef, "gui", "4_73475cb40a568.nwd") + refFile = os.path.join(refDir, "gui", "4_73475cb40a568.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -638,7 +646,7 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef # This should give us back the file as it was before projFile = os.path.join(nwLipsum, "content", "71ee45a3c0db9.nwd") testFile = os.path.join(nwTempGUI, "4_71ee45a3c0db9.nwd") - refFile = os.path.join(nwRef, "gui", "4_73475cb40a568.nwd") + refFile = os.path.join(refDir, "gui", "4_73475cb40a568.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [1, 2, 3]) @@ -662,19 +670,19 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef projFile = os.path.join(nwLipsum, "content", "25fc0e7096fc6.nwd") testFile = os.path.join(nwTempGUI, "5_25fc0e7096fc6.nwd") - refFile = os.path.join(nwRef, "gui", "5_25fc0e7096fc6.nwd") + refFile = os.path.join(refDir, "gui", "5_25fc0e7096fc6.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "31489056e0916.nwd") testFile = os.path.join(nwTempGUI, "5_31489056e0916.nwd") - refFile = os.path.join(nwRef, "gui", "5_31489056e0916.nwd") + refFile = os.path.join(refDir, "gui", "5_31489056e0916.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "98010bd9270f9.nwd") testFile = os.path.join(nwTempGUI, "5_98010bd9270f9.nwd") - refFile = os.path.join(nwRef, "gui", "5_98010bd9270f9.nwd") + refFile = os.path.join(refDir, "gui", "5_98010bd9270f9.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -700,31 +708,31 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef projFile = os.path.join(nwLipsum, "content", "1a6562590ef19.nwd") testFile = os.path.join(nwTempGUI, "5_25fc0e7096fc6.nwd") - refFile = os.path.join(nwRef, "gui", "5_25fc0e7096fc6.nwd") + refFile = os.path.join(refDir, "gui", "5_25fc0e7096fc6.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [1, 2, 3]) projFile = os.path.join(nwLipsum, "content", "031b4af5197ec.nwd") testFile = os.path.join(nwTempGUI, "5_031b4af5197ec.nwd") - refFile = os.path.join(nwRef, "gui", "5_031b4af5197ec.nwd") + refFile = os.path.join(refDir, "gui", "5_031b4af5197ec.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "41cfc0d1f2d12.nwd") testFile = os.path.join(nwTempGUI, "5_41cfc0d1f2d12.nwd") - refFile = os.path.join(nwRef, "gui", "5_41cfc0d1f2d12.nwd") + refFile = os.path.join(refDir, "gui", "5_41cfc0d1f2d12.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "2858dcd1057d3.nwd") testFile = os.path.join(nwTempGUI, "5_2858dcd1057d3.nwd") - refFile = os.path.join(nwRef, "gui", "5_2858dcd1057d3.nwd") + refFile = os.path.join(refDir, "gui", "5_2858dcd1057d3.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "2fca346db6561.nwd") testFile = os.path.join(nwTempGUI, "5_2fca346db6561.nwd") - refFile = os.path.join(nwRef, "gui", "5_2fca346db6561.nwd") + refFile = os.path.join(refDir, "gui", "5_2fca346db6561.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -732,7 +740,7 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef nwGUI.closeMain() @pytest.mark.gui -def testNewProjectWizard(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): +def testNewProjectWizard(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir): if sys.platform.startswith("darwin"): # Disable for macOS because the test segfaults on QWizard.show() @@ -744,7 +752,7 @@ def testNewProjectWizard(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): ProjWizardCustomPage, ProjWizardFinalPage ) - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -929,8 +937,8 @@ def testNewProjectWizard(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): nwGUI.close() @pytest.mark.gui -def testLoadProject(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp]) +def testLoadProject(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1009,8 +1017,8 @@ def testLoadProject(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): nwGUI.close() @pytest.mark.gui -def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp, nwRef, tmpConf): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp]) +def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir, refDir, tmpConf): + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1195,20 +1203,21 @@ def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp, nwRef, tmpC # qtbot.stopForInteraction() nwGUI.closeMain() - refConf = os.path.join(nwRef, "novelwriter_prefs.conf") + refConf = os.path.join(refDir, "novelwriter_prefs.conf") projConf = os.path.join(nwGUI.mainConf.confPath, "novelwriter.conf") - testConf = os.path.join(nwTemp, "novelwriter_prefs.conf") + testConf = os.path.join(tmpDir, "novelwriter_prefs.conf") copyfile(projConf, testConf) ignoreLines = [ 2, # Timestamp - 11, 12, 13, 14, 15, 16, 17, # Window sizes - 7, 27, # Fonts (depends on system default) + 9, # Release Notes + 12, 13, 14, 15, 16, 17, 18, # Window sizes + 7, 28, # Fonts (depends on system default) ] assert cmpFiles(testConf, refConf, ignoreLines) @pytest.mark.gui -def testQuotesDialog(qtbot, yesToAll, nwMinimal, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) +def testQuotesDialog(qtbot, yesToAll, nwMinimal, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1237,15 +1246,15 @@ def testQuotesDialog(qtbot, yesToAll, nwMinimal, nwTemp): nwGUI.close() @pytest.mark.gui -def testDialogsOpenClose(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) +def testDialogsOpenClose(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) qtbot.wait(stepDelay) - monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args, **kwargs: nwTemp) - assert nwGUI.selectProjectPath() == nwTemp + monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args, **kwargs: tmpDir) + assert nwGUI.selectProjectPath() == tmpDir # qtbot.stopForInteraction() nwGUI.closeMain() diff --git a/tests/test_gui.py b/tests/test_gui_main.py similarity index 92% rename from tests/test_gui.py rename to tests/test_gui_main.py index fcf96c06..b56c525b 100644 --- a/tests/test_gui.py +++ b/tests/test_gui_main.py @@ -4,12 +4,10 @@ import nw import pytest -import logging import os -import sys from shutil import copyfile -from nwtools import cmpFiles +from tools import cmpFiles from PyQt5.QtCore import Qt, QUrl, QPoint, QItemSelectionModel from PyQt5.QtGui import QTextCursor, QColor, QPixmap, QIcon, QTextBlock @@ -27,85 +25,9 @@ typeDelay = 1 stepDelay = 20 @pytest.mark.gui -def testLaunch(qtbot, monkeypatch, nwFuncTemp, nwTemp): +def testDocEditor(qtbot, yesToAll, fncDir, nwTempGUI, refDir, tmpDir): - # Defaults - nwGUI = nw.main( - ["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp, "--style=Fusion"] - ) - assert nw.logger.getEffectiveLevel() == logging.WARNING - nwGUI.closeMain() - nwGUI.close() - - # Log Levels - nwGUI = nw.main( - ["--testmode", "--info", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] - ) - assert nw.logger.getEffectiveLevel() == logging.INFO - nwGUI.closeMain() - nwGUI.close() - - nwGUI = nw.main( - ["--testmode", "--debug", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] - ) - assert nw.logger.getEffectiveLevel() == logging.DEBUG - nwGUI.closeMain() - nwGUI.close() - - nwGUI = nw.main( - ["--testmode", "--verbose", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] - ) - assert nw.logger.getEffectiveLevel() == 5 - nwGUI.closeMain() - nwGUI.close() - - # Help and Version - with pytest.raises(SystemExit) as ex: - nwGUI = nw.main( - ["--testmode", "--help", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] - ) - nwGUI.closeMain() - nwGUI.close() - assert ex.value.code == 0 - - with pytest.raises(SystemExit) as ex: - nwGUI = nw.main( - ["--testmode", "--version", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] - ) - nwGUI.closeMain() - nwGUI.close() - assert ex.value.code == 0 - - # Invalid options - with pytest.raises(SystemExit) as ex: - nwGUI = nw.main( - ["--testmode", "--invalid", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] - ) - nwGUI.closeMain() - nwGUI.close() - assert ex.value.code == 2 - - # Simulate import error - monkeypatch.setitem(sys.modules, "lxml", None) - monkeypatch.setattr("sys.hexversion", 0x0) - monkeypatch.setattr("nw.CONFIG.verQtValue", 50000) - monkeypatch.setattr("nw.CONFIG.verPyQtValue", 50000) - with pytest.raises(SystemExit) as ex: - nwGUI = nw.main( - ["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] - ) - nwGUI.closeMain() - nwGUI.close() - assert ex.value.code & 4 == 4 # Python version not satisfied - assert ex.value.code & 8 == 8 # Qt version not satisfied - assert ex.value.code & 16 == 16 # PyQt version not satisfied - assert ex.value.code & 32 == 32 # lxml package missing - monkeypatch.undo() - -@pytest.mark.gui -def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): - - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -113,7 +35,7 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): # Create new, save, close project nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) + assert nwGUI.newProject({"projPath": fncDir}) assert nwGUI.saveProject() assert nwGUI.closeProject() @@ -130,9 +52,9 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): assert not nwGUI.theProject.spellCheck # Check the files - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "0_nwProject.nwx") - refFile = os.path.join(nwRef, "gui", "0_nwProject.nwx") + refFile = os.path.join(refDir, "gui", "0_nwProject.nwx") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) qtbot.wait(stepDelay) @@ -140,7 +62,7 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): # qtbot.stopForInteraction() # Re-open project - assert nwGUI.openProject(nwFuncTemp) + assert nwGUI.openProject(fncDir) qtbot.wait(stepDelay) # Check that we loaded the data @@ -148,8 +70,8 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): assert len(nwGUI.theProject.projTree._treeOrder) == 8 assert len(nwGUI.theProject.projTree._treeRoots) == 4 assert nwGUI.theProject.projTree.trashRoot() is None - assert nwGUI.theProject.projPath == nwFuncTemp - assert nwGUI.theProject.projMeta == os.path.join(nwFuncTemp, "meta") + assert nwGUI.theProject.projPath == fncDir + assert nwGUI.theProject.projMeta == os.path.join(fncDir, "meta") assert nwGUI.theProject.projFile == "nwProject.nwx" assert nwGUI.theProject.projName == "New Project" assert nwGUI.theProject.bookTitle == "" @@ -379,33 +301,33 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): assert nwGUI.saveProject() # Check the files - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "1_nwProject.nwx") - refFile = os.path.join(nwRef, "gui", "1_nwProject.nwx") + refFile = os.path.join(refDir, "gui", "1_nwProject.nwx") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) - projFile = os.path.join(nwFuncTemp, "content", "031b4af5197ec.nwd") + projFile = os.path.join(fncDir, "content", "031b4af5197ec.nwd") testFile = os.path.join(nwTempGUI, "1_031b4af5197ec.nwd") - refFile = os.path.join(nwRef, "gui", "1_031b4af5197ec.nwd") + refFile = os.path.join(refDir, "gui", "1_031b4af5197ec.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) - projFile = os.path.join(nwFuncTemp, "content", "1a6562590ef19.nwd") + projFile = os.path.join(fncDir, "content", "1a6562590ef19.nwd") testFile = os.path.join(nwTempGUI, "1_1a6562590ef19.nwd") - refFile = os.path.join(nwRef, "gui", "1_1a6562590ef19.nwd") + refFile = os.path.join(refDir, "gui", "1_1a6562590ef19.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) - projFile = os.path.join(nwFuncTemp, "content", "0e17daca5f3e1.nwd") + projFile = os.path.join(fncDir, "content", "0e17daca5f3e1.nwd") testFile = os.path.join(nwTempGUI, "1_0e17daca5f3e1.nwd") - refFile = os.path.join(nwRef, "gui", "1_0e17daca5f3e1.nwd") + refFile = os.path.join(refDir, "gui", "1_0e17daca5f3e1.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) - projFile = os.path.join(nwFuncTemp, "content", "41cfc0d1f2d12.nwd") + projFile = os.path.join(fncDir, "content", "41cfc0d1f2d12.nwd") testFile = os.path.join(nwTempGUI, "1_41cfc0d1f2d12.nwd") - refFile = os.path.join(nwRef, "gui", "1_41cfc0d1f2d12.nwd") + refFile = os.path.join(refDir, "gui", "1_41cfc0d1f2d12.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -414,9 +336,9 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): nwGUI.close() @pytest.mark.gui -def testDocViewer(qtbot, yesToAll, nwLipsum, nwTemp): +def testDocViewer(qtbot, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -566,9 +488,9 @@ def testDocViewer(qtbot, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testProjectTree(qtbot, yesToAll, nwMinimal, nwTemp): +def testProjectTree(qtbot, yesToAll, nwMinimal, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -690,9 +612,9 @@ def testProjectTree(qtbot, yesToAll, nwMinimal, nwTemp): nwGUI.close() @pytest.mark.gui -def testEditFormatMenu(qtbot, yesToAll, nwLipsum, nwTemp): +def testEditFormatMenu(qtbot, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -927,9 +849,9 @@ def testEditFormatMenu(qtbot, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testContextMenu(qtbot, yesToAll, nwLipsum, nwTemp): +def testContextMenu(qtbot, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1013,15 +935,15 @@ def testContextMenu(qtbot, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) +def testInsertMenu(qtbot, monkeypatch, fncDir, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) qtbot.wait(stepDelay) nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) + assert nwGUI.newProject({"projPath": fncDir}) assert nwGUI.treeView._getTreeItem("0e17daca5f3e1") is not None @@ -1162,7 +1084,7 @@ def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, nwTemp): assert not nwGUI.importDocument() # Then a valid path, but bot a file that exists - theFile = os.path.join(nwTemp, "import.txt") + theFile = os.path.join(tmpDir, "import.txt") monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args, **kwards: (theFile, "")) assert not nwGUI.importDocument() @@ -1205,16 +1127,16 @@ def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, nwTemp): assert len(theBits) == 3 assert theBits[0] == "File details for the currently open file" assert theBits[1] == "Handle: 0e17daca5f3e1" - assert theBits[2] == "Location: %s" % os.path.join(nwFuncTemp, "content", "0e17daca5f3e1.nwd") + assert theBits[2] == "Location: %s" % os.path.join(fncDir, "content", "0e17daca5f3e1.nwd") # qtbot.stopForInteraction() nwGUI.closeMain() nwGUI.close() @pytest.mark.gui -def testTextSearch(qtbot, monkeypatch, yesToAll, nwLipsum, nwTemp): +def testTextSearch(qtbot, monkeypatch, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1392,9 +1314,9 @@ def testTextSearch(qtbot, monkeypatch, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testOutline(qtbot, yesToAll, nwLipsum, nwTemp): +def testOutline(qtbot, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1453,9 +1375,9 @@ def testOutline(qtbot, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testThemes(qtbot, yesToAll, nwMinimal, nwTemp): +def testThemes(qtbot, yesToAll, nwMinimal, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1478,7 +1400,7 @@ def testThemes(qtbot, yesToAll, nwMinimal, nwTemp): # Re-open assert nw.CONFIG.confPath == nwMinimal - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) assert nwGUI.mainConf.confPath == nwMinimal qtbot.addWidget(nwGUI) nwGUI.show() diff --git a/tests/test_project.py b/tests/test_project.py deleted file mode 100644 index 5ed9d261..00000000 --- a/tests/test_project.py +++ /dev/null @@ -1,679 +0,0 @@ -# -*- coding: utf-8 -*- -"""novelWriter Project Class Tester -""" - -import pytest -import os - -from shutil import copyfile -from zipfile import ZipFile - -from nwtools import cmpFiles - -from nw.core.project import NWProject -from nw.core.document import NWDoc -from nw.core.spellcheck import NWSpellEnchant, NWSpellSimple -from nw.constants import nwConst, nwItemClass, nwItemType, nwItemLayout, nwFiles - -@pytest.mark.project -def testProjectNewOpenSave(nwFuncTemp, nwTempProj, nwRef, nwTemp, nwDummy): - """Test that a basic project can be created, and opened and saved. - """ - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "1_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "1_nwProject.nwx") - - theProject = NWProject(nwDummy) - theProject.projTree.setSeed(42) - - # Setting no data should fail - assert not theProject.newProject({}) - - # Try again with a proper path - assert theProject.newProject({"projPath": nwFuncTemp}) - assert theProject.setProjectPath(nwFuncTemp) - assert theProject.saveProject() - assert theProject.closeProject() - - # Creating the project once more should fail - assert not theProject.newProject({"projPath": nwFuncTemp}) - - # Check the new project - copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) - - # Open again - assert theProject.openProject(projFile) - - # Save and close - assert theProject.saveProject() - assert theProject.closeProject() - copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) - assert not theProject.projChanged - - # Open a second time - assert theProject.openProject(projFile) - assert not theProject.openProject(projFile) - assert theProject.openProject(projFile, overrideLock=True) - assert theProject.saveProject() - assert theProject.closeProject() - copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) - -@pytest.mark.project -def testProjectNewRoot(nwFuncTemp, nwTempProj, nwRef, nwDummy): - """Check that new root folders can be added to the project. - """ - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "2_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "2_nwProject.nwx") - - theProject = NWProject(nwDummy) - theProject.projTree.setSeed(42) - - assert theProject.newProject({"projPath": nwFuncTemp}) - assert theProject.setProjectPath(nwFuncTemp) - assert theProject.saveProject() - assert theProject.closeProject() - assert theProject.openProject(projFile) - - assert isinstance(theProject.newRoot("Novel", nwItemClass.NOVEL), type(None)) - assert isinstance(theProject.newRoot("Plot", nwItemClass.PLOT), type(None)) - assert isinstance(theProject.newRoot("Character", nwItemClass.CHARACTER), type(None)) - assert isinstance(theProject.newRoot("World", nwItemClass.WORLD), type(None)) - assert isinstance(theProject.newRoot("Timeline", nwItemClass.TIMELINE), str) - assert isinstance(theProject.newRoot("Object", nwItemClass.OBJECT), str) - assert isinstance(theProject.newRoot("Custom1", nwItemClass.CUSTOM), str) - assert isinstance(theProject.newRoot("Custom2", nwItemClass.CUSTOM), str) - - assert theProject.projChanged - assert theProject.saveProject() - assert theProject.closeProject() - - copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) - assert not theProject.projChanged - -@pytest.mark.project -def testProjectNewFile(nwFuncTemp, nwTempProj, nwRef, nwDummy): - """Check that new files can be added to the project. - """ - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "3_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "3_nwProject.nwx") - - theProject = NWProject(nwDummy) - theProject.projTree.setSeed(42) - - assert theProject.newProject({"projPath": nwFuncTemp}) - assert theProject.setProjectPath(nwFuncTemp) - assert theProject.saveProject() - assert theProject.closeProject() - assert theProject.openProject(projFile) - - assert isinstance(theProject.newFile("Hello", nwItemClass.NOVEL, "31489056e0916"), str) - assert isinstance(theProject.newFile("Jane", nwItemClass.CHARACTER, "71ee45a3c0db9"), str) - assert theProject.projChanged - assert theProject.saveProject() - assert theProject.closeProject() - - copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) - assert not theProject.projChanged - -@pytest.mark.project -def testProjectNewCustomA(nwFuncTemp, nwTempProj, nwRef, nwDummy): - """Create a new project from a project wizard dictionary. - Custom type with chapters and scenes. - """ - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "4_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "4_nwProject.nwx") - - projData = { - "projName": "Test Custom", - "projTitle": "Test Novel", - "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": nwFuncTemp, - "popSample": False, - "popMinimal": False, - "popCustom": True, - "addRoots": [ - nwItemClass.PLOT, - nwItemClass.CHARACTER, - nwItemClass.WORLD, - nwItemClass.TIMELINE, - nwItemClass.OBJECT, - nwItemClass.ENTITY, - ], - "numChapters": 3, - "numScenes": 3, - "chFolders": True, - } - theProject = NWProject(nwDummy) - theProject.projTree.setSeed(42) - - assert theProject.newProject(projData) - assert theProject.saveProject() - assert theProject.closeProject() - - copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) - -@pytest.mark.project -def testProjectNewCustomB(nwFuncTemp, nwTempProj, nwRef, nwDummy): - """Create a new project from a project wizard dictionary. - Custom type without chapters, but with scenes. - """ - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "5_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "5_nwProject.nwx") - - projData = { - "projName": "Test Custom", - "projTitle": "Test Novel", - "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": nwFuncTemp, - "popSample": False, - "popMinimal": False, - "popCustom": True, - "addRoots": [ - nwItemClass.PLOT, - nwItemClass.CHARACTER, - nwItemClass.WORLD, - nwItemClass.TIMELINE, - nwItemClass.OBJECT, - nwItemClass.ENTITY, - ], - "numChapters": 0, - "numScenes": 6, - "chFolders": True, - } - theProject = NWProject(nwDummy) - theProject.projTree.setSeed(42) - - assert theProject.newProject(projData) - assert theProject.saveProject() - assert theProject.closeProject() - - copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) - -@pytest.mark.project -def testProjectNewSampleA(nwFuncTemp, nwConf, nwDummy, nwTemp): - """Check that we can create a new project can be created from the - provided sample project via a zip file. - """ - projData = { - "projName": "Test Sample", - "projTitle": "Test Novel", - "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": nwFuncTemp, - "popSample": True, - "popMinimal": False, - "popCustom": False, - } - theProject = NWProject(nwDummy) - theProject.projTree.setSeed(42) - theProject.mainConf = nwConf - - # Sample set, but no path - assert not theProject.newProject({"popSample": True}) - - # Force the lookup path for assets to our temp folder - srcSample = os.path.abspath(os.path.join(nwConf.appRoot, "sample")) - dstSample = os.path.join(nwTemp, "sample.zip") - nwConf.assetPath = nwTemp - - # Create and open a defective zip file - with open(dstSample, mode="w+") as outFile: - outFile.write("foo") - - assert not theProject.newProject(projData) - os.unlink(dstSample) - - # Create a real zip file, and unpack it - with ZipFile(dstSample, "w") as zipObj: - zipObj.write(os.path.join(srcSample, "nwProject.nwx"), "nwProject.nwx") - for docFile in os.listdir(os.path.join(srcSample, "content")): - srcDoc = os.path.join(srcSample, "content", docFile) - zipObj.write(srcDoc, "content/"+docFile) - - assert theProject.newProject(projData) - assert theProject.openProject(nwFuncTemp) - assert theProject.projName == "Sample Project" - assert theProject.saveProject() - assert theProject.closeProject() - os.unlink(dstSample) - -@pytest.mark.project -def testProjectNewSampleB(monkeypatch, nwFuncTemp, nwConf, nwDummy, nwTemp): - """Check that we can create a new project can be created from the - provided sample project folder. - """ - projData = { - "projName": "Test Sample", - "projTitle": "Test Novel", - "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": nwFuncTemp, - "popSample": True, - "popMinimal": False, - "popCustom": False, - } - theProject = NWProject(nwDummy) - theProject.projTree.setSeed(42) - theProject.mainConf = nwConf - - # Make sure we do not pick up the nw/assets/sample.zip file - nwConf.assetPath = nwTemp - - # Set a fake project file name - monkeypatch.setattr(nwFiles, "PROJ_FILE", "nothing.nwx") - assert not theProject.newProject(projData) - - monkeypatch.setattr(nwFiles, "PROJ_FILE", "nwProject.nwx") - assert theProject.newProject(projData) - assert theProject.openProject(nwFuncTemp) - assert theProject.projName == "Sample Project" - assert theProject.saveProject() - assert theProject.closeProject() - - # Misdirect the appRoot path so neither is possible - nwConf.appRoot = nwTemp - assert not theProject.newProject(projData) - -@pytest.mark.project -def testProjectMethods(monkeypatch, nwMinimal, nwDummy): - """Test other project class methods and functions. - """ - theProject = NWProject(nwDummy) - theProject.projTree.setSeed(42) - assert theProject.openProject(nwMinimal) - assert theProject.projPath == nwMinimal - - # Setting project path - assert theProject.setProjectPath(None) - assert theProject.projPath is None - assert theProject.setProjectPath("") - assert theProject.projPath is None - assert theProject.setProjectPath("~") - assert theProject.projPath == os.path.expanduser("~") - - # Create a new folder and populate it - projPath = os.path.join(nwMinimal, "dummy1") - assert theProject.setProjectPath(projPath, newProject=True) - - # Make os.mkdir fail - def altMkdir(*args): - raise Exception("Oops!") - - monkeypatch.setattr("os.mkdir", altMkdir) - projPath = os.path.join(nwMinimal, "dummy2") - assert not theProject.setProjectPath(projPath, newProject=True) - - # Project Name - assert theProject.setProjectName(" A Name ") - assert theProject.projName == "A Name" - - # Project Title - assert theProject.setBookTitle(" A Title ") - assert theProject.bookTitle == "A Title" - - # Project Authors - assert not theProject.setBookAuthors([]) - assert theProject.setBookAuthors(" Jane Doe \n John Doh \n ") - assert theProject.bookAuthors == ["Jane Doe", "John Doh"] - -@pytest.mark.project -def testDocMeta(nwDummy, nwLipsum): - """Check that the document meta data string is parsed correctly. - """ - theProject = NWProject(nwDummy) - theProject.projTree.setSeed(42) - assert theProject.openProject(nwLipsum) - - aDoc = NWDoc(theProject, nwDummy) - assert aDoc.openDocument("47666c91c7ccf") - theName, theParent, theClass, theLayout = aDoc.getMeta() - - assert theName == "Scene Five" - assert theParent == "6bd935d2490cd" - assert theClass == nwItemClass.NOVEL - assert theLayout == nwItemLayout.SCENE - - aDoc._docMeta = {"stuff": None} - theName, theParent, theClass, theLayout = aDoc.getMeta() - assert theName == "" - assert theParent is None - assert theClass is None - assert theLayout is None - -@pytest.mark.project -def testSpellEnchant(nwTemp, nwConf): - wList = os.path.join(nwTemp, "wordlist.txt") - with open(wList, mode="w") as wFile: - wFile.write("a_word\nb_word\nc_word\n") - - spChk = NWSpellEnchant() - spChk.mainConf = nwConf - spChk.setLanguage("en", wList) - - assert spChk.checkWord("a_word") - assert spChk.checkWord("b_word") - assert spChk.checkWord("c_word") - assert not spChk.checkWord("d_word") - - spChk.addWord("d_word") - assert spChk.checkWord("d_word") - - wSuggest = spChk.suggestWords("wrod") - assert len(wSuggest) > 0 - assert "word" in wSuggest - - dList = spChk.listDictionaries() - assert len(dList) > 0 - - aTag, aName = spChk.describeDict() - assert aTag == "en" - assert aName != "" - -@pytest.mark.project -def testSpellSimple(nwTemp, nwConf): - wList = os.path.join(nwTemp, "wordlist.txt") - with open(wList, mode="w") as wFile: - wFile.write("a_word\nb_word\nc_word\n") - - spChk = NWSpellSimple() - spChk.mainConf = nwConf - spChk.setLanguage("en", wList) - - assert spChk.checkWord("a_word") - assert spChk.checkWord("b_word") - assert spChk.checkWord("c_word") - assert not spChk.checkWord("d_word") - - spChk.addWord("d_word") - assert spChk.checkWord("d_word") - - wSuggest = spChk.suggestWords("wrod") - assert len(wSuggest) > 0 - assert "word" in wSuggest - - dList = spChk.listDictionaries() - assert len(dList) > 0 - - aTag, aName = spChk.describeDict() - assert aTag == "en" - assert aName == nwConst.SP_INTERNAL - -@pytest.mark.project -def testProjectOptions(nwDummy, nwLipsum): - """Test the class that holds all the GUI state user options that are - tied to the current open project. Non-project related GUI options - are handled by the Config class. - """ - theProject = NWProject(nwDummy) - assert theProject.projMeta is None - - theOpts = theProject.optState - assert not theOpts.loadSettings() - assert not theOpts.saveSettings() - - # No Settings - assert theProject.openProject(nwLipsum) - assert theOpts.loadSettings() - assert theOpts.saveSettings() - assert str(theOpts.theState) == r"{}" - - # Read Invalid Settings and Filter - stateFile = os.path.join(theProject.projMeta, nwFiles.OPTS_FILE) - with open(stateFile, mode="w", encoding="utf8") as outFile: - outFile.write( - r'{"GuiProjectSettings": {"winWidth": 100, "winHeight": 50}, "NoGroup": {"NoName": 0}}' - ) - assert theOpts.loadSettings() - assert str(theOpts.theState) == r"{'GuiProjectSettings': {'winWidth': 100, 'winHeight': 50}}" - - # Set New Settings - assert not theOpts.setValue("NoGroup", "NoName", None) - assert not theOpts.setValue("GuiProjectSettings", "NoName", None) - assert theOpts.setValue("GuiProjectSettings", "winWidth", 200) - assert theOpts.setValue("GuiProjectSettings", "winHeight", 80) - assert str(theOpts.theState) == r"{'GuiProjectSettings': {'winWidth': 200, 'winHeight': 80}}" - - # Check Read/Write Types - - ## String - assert theOpts.setValue("GuiWritingStats", "winWidth", "123") - assert isinstance(theOpts.getString("GuiWritingStats", "winWidth", "456"), str) - assert theOpts.getString("GuiWritingStats", "NoName", "456") == "456" - - ## Int - assert theOpts.setValue("GuiWritingStats", "winWidth", "123") - assert isinstance(theOpts.getInt("GuiWritingStats", "winWidth", 456), int) - assert theOpts.getInt("GuiWritingStats", "NoName", 456) == 456 - assert theOpts.setValue("GuiWritingStats", "winWidth", "True") - assert theOpts.getInt("GuiWritingStats", "NoName", 456) == 456 - - ## Float - assert theOpts.setValue("GuiWritingStats", "winWidth", "123") - assert isinstance(theOpts.getFloat("GuiWritingStats", "winWidth", 456.0), float) - assert theOpts.getFloat("GuiWritingStats", "NoName", 456.0) == 456.0 - assert theOpts.setValue("GuiWritingStats", "winWidth", "True") - assert theOpts.getFloat("GuiWritingStats", "winWidth", 456.0) == 456.0 - - ## Bool - assert theOpts.setValue("GuiWritingStats", "winWidth", True) - assert isinstance(theOpts.getBool("GuiWritingStats", "winWidth", False), bool) - assert theOpts.getFloat("GuiWritingStats", "NoName", False) is False - assert theOpts.setValue("GuiWritingStats", "winWidth", "True") - assert theOpts.getFloat("GuiWritingStats", "winWidth", False) is False - -@pytest.mark.project -def testProjectOrphanedFiles(nwDummy, nwLipsum): - """Check that files in the content folder that are not tracked in - the project XML file are handled correctly by the orphaned files - function. It should also restore as much meta data as possible from - the meta line at the top of the document file. - """ - theProject = NWProject(nwDummy) - assert theProject.openProject(nwLipsum) - assert theProject.projTree["636b6aa9b697b"] is None - assert theProject.closeProject() - - # First Item with Meta Data - orphPath = os.path.join(nwLipsum, "content", "636b6aa9b697b.nwd") - with open(orphPath, mode="w", encoding="utf8") as outFile: - outFile.write("%%~name:Mars\n") - outFile.write("%%~path:5eaea4e8cdee8/636b6aa9b697b\n") - outFile.write("%%~kind:WORLD/NOTE\n") - outFile.write("%%~invalid\n") - outFile.write("\n") - - # Second Item without Meta Data - orphPath = os.path.join(nwLipsum, "content", "736b6aa9b697b.nwd") - with open(orphPath, mode="w", encoding="utf8") as outFile: - outFile.write("\n") - - # Invalid File Name - dummyPath = os.path.join(nwLipsum, "content", "636b6aa9b697b.txt") - with open(dummyPath, mode="w", encoding="utf8") as outFile: - outFile.write("\n") - - # Invalid File Name - dummyPath = os.path.join(nwLipsum, "content", "636b6aa9b697bb.nwd") - with open(dummyPath, mode="w", encoding="utf8") as outFile: - outFile.write("\n") - - # Invalid File Name - dummyPath = os.path.join(nwLipsum, "content", "abcdefghijklm.nwd") - with open(dummyPath, mode="w", encoding="utf8") as outFile: - outFile.write("\n") - - assert theProject.openProject(nwLipsum) - assert theProject.projPath is not None - assert theProject.projTree["636b6aa9b697bb"] is None - assert theProject.projTree["abcdefghijklm"] is None - - # First Item with Meta Data - oItem = theProject.projTree["636b6aa9b697b"] - assert oItem is not None - assert oItem.itemName == "Mars" - assert oItem.itemHandle == "636b6aa9b697b" - assert oItem.itemParent is None - assert oItem.itemClass == nwItemClass.WORLD - assert oItem.itemType == nwItemType.FILE - assert oItem.itemLayout == nwItemLayout.NOTE - - # Second Item without Meta Data - oItem = theProject.projTree["736b6aa9b697b"] - assert oItem is not None - assert oItem.itemName == "Orphaned File 1" - assert oItem.itemHandle == "736b6aa9b697b" - assert oItem.itemParent is None - assert oItem.itemClass == nwItemClass.NO_CLASS - assert oItem.itemType == nwItemType.FILE - assert oItem.itemLayout == nwItemLayout.NO_LAYOUT - - assert theProject.saveProject(nwLipsum) - assert theProject.closeProject() - -@pytest.mark.project -def testProjectOldFormat(nwDummy, nwOldProj): - """Test that a project folder structure of version 1.0 can be - converted to the latest folder structure. Version 1.0 split the - documents into 'data_0' ... 'data_f' folders, which are now all - contained in a single 'content' folder. - """ - theProject = NWProject(nwDummy) - theProject.mainConf.showGUI = False - - # Create dummy files for known legacy files - deleteFiles = [ - os.path.join(nwOldProj, "cache", "nwProject.nwx.0"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.1"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.2"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.3"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.4"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.5"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.6"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.7"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.8"), - os.path.join(nwOldProj, "cache", "nwProject.nwx.9"), - os.path.join(nwOldProj, "meta", "mainOptions.json"), - os.path.join(nwOldProj, "meta", "exportOptions.json"), - os.path.join(nwOldProj, "meta", "outlineOptions.json"), - os.path.join(nwOldProj, "meta", "timelineOptions.json"), - os.path.join(nwOldProj, "meta", "docMergeOptions.json"), - os.path.join(nwOldProj, "meta", "sessionLogOptions.json"), - ] - - # Add some files that shouldn't be there - deleteFiles.append(os.path.join(nwOldProj, "data_f", "whatnow.nwd")) - deleteFiles.append(os.path.join(nwOldProj, "data_f", "whatnow.txt")) - - # Add some folders that shouldn't be there - os.mkdir(os.path.join(nwOldProj, "stuff")) - os.mkdir(os.path.join(nwOldProj, "data_1", "stuff")) - - # Create dummy files - os.mkdir(os.path.join(nwOldProj, "cache")) - for aFile in deleteFiles: - with open(aFile, mode="w+", encoding="utf8") as outFile: - outFile.write("Hi") - for aFile in deleteFiles: - assert os.path.isfile(aFile) - - # Open project and check that files that are not supposed to be - # there have been removed - assert theProject.openProject(nwOldProj) - for aFile in deleteFiles: - assert not os.path.isfile(aFile) - - assert not os.path.isdir(os.path.join(nwOldProj, "data_1", "stuff")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_1")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_7")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_8")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_9")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_a")) - assert not os.path.isdir(os.path.join(nwOldProj, "data_f")) - - # Check stuff that has been moved - assert os.path.isdir(os.path.join(nwOldProj, "junk")) - assert os.path.isdir(os.path.join(nwOldProj, "junk", "stuff")) - assert os.path.isfile(os.path.join(nwOldProj, "junk", "whatnow.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "junk", "whatnow.txt")) - - # Check that files we want to keep are in the right place - assert os.path.isdir(os.path.join(nwOldProj, "cache")) - assert os.path.isdir(os.path.join(nwOldProj, "content")) - assert os.path.isdir(os.path.join(nwOldProj, "meta")) - - assert os.path.isfile(os.path.join(nwOldProj, "content", "f528d831f5b24.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "88124a4292d8b.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "91239bf2f8b69.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "19752e7f9d8af.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "a764d5acf5a21.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "9058ae29f0dfd.nwd")) - assert os.path.isfile(os.path.join(nwOldProj, "content", "7ff63b8afc4cd.nwd")) - - assert os.path.isfile(os.path.join(nwOldProj, "meta", "tagsIndex.json")) - assert os.path.isfile(os.path.join(nwOldProj, "meta", "sessionInfo.log")) - - # Close the project - theProject.closeProject() - - # Check that new files have been created - assert os.path.isfile(os.path.join(nwOldProj, "meta", "guiOptions.json")) - assert os.path.isfile(os.path.join(nwOldProj, "meta", "sessionStats.log")) - assert os.path.isfile(os.path.join(nwOldProj, "ToC.txt")) - -@pytest.mark.project -def testProjectBackup(nwDummy, nwMinimal, nwTemp): - """Test the automated backup feature of the project class. The test - creates a backup of the Minimal test project, and then unzips the - backupd file and checks that the project XML file is identical to - the original file. - """ - theProject = NWProject(nwDummy) - assert theProject.openProject(nwMinimal) - - # Test faulty settings - # Invalid path - theProject.mainConf.backupPath = None - assert not theProject.zipIt(doNotify=False) - - # Missing project name - theProject.mainConf.backupPath = nwTemp - theProject.projName = "" - assert not theProject.zipIt(doNotify=False) - - # Non-existent folder - theProject.mainConf.backupPath = os.path.join(nwTemp, "nonexistent") - theProject.projName = "Test Minimal" - assert not theProject.zipIt(doNotify=False) - - # Same folder as project (causes infinite loop in zipping) - theProject.mainConf.backupPath = nwMinimal - assert not theProject.zipIt(doNotify=False) - - # Test correct settings - theProject.mainConf.backupPath = nwTemp - assert theProject.zipIt(doNotify=False) - - theFiles = os.listdir(os.path.join(nwTemp, "Test Minimal")) - assert len(theFiles) == 1 - - theZip = theFiles[0] - assert theZip[:12] == "Backup from " - assert theZip[-4:] == ".zip" - - # Extract the archive - with ZipFile(os.path.join(nwTemp, "Test Minimal", theZip), "r") as inZip: - inZip.extractall(os.path.join(nwTemp, "extract")) - - # Check that the main project file was restored - assert cmpFiles( - os.path.join(nwMinimal, "nwProject.nwx"), os.path.join(nwTemp, "extract", "nwProject.nwx") - ) diff --git a/tests/nwtools.py b/tests/tools.py similarity index 83% rename from tests/nwtools.py rename to tests/tools.py index e35d9e08..185a9fa0 100644 --- a/tests/nwtools.py +++ b/tests/tools.py @@ -70,3 +70,15 @@ def getGuiItem(theName): if qWidget.objectName() == theName: return qWidget return None + +def readFile(fileName): + """Returns the content of a file as a string. + """ + with open(fileName, mode="r", encoding="utf8") as inFile: + return inFile.read() + +def writeFile(fileName, fileData): + """Write the contents of a string to a file. + """ + with open(fileName, mode="w", encoding="utf8") as outFile: + outFile.write(fileData)