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:
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 @@
+
+
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.
+
+
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%s>\n" % (h1, hStyle, aNm, tHead, h1))
+ tmpResult.append("<%s%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 @@
-
+ NovelROOTNOVELNewFalse
-
+ PlotROOTPLOTNewFalse
-
+ CharactersROOTCHARACTERNewFalse
-
+ LocationsROOTWORLDNewFalse
-
+ TimelineROOTTIMELINENewFalse
-
+ ObjectsROOTOBJECTNewFalse
-
+ EntityROOTENTITYNewFalse
-
+ Title PageFILENOVEL
@@ -102,14 +102,14 @@
00
-
+ Chapter 1FOLDERNOVELNewFalse
-
+ Chapter 1FILENOVEL
@@ -121,7 +121,7 @@
00
-
+ Scene 1.1FILENOVEL
@@ -133,7 +133,7 @@
00
-
+ Scene 1.2FILENOVEL
@@ -145,7 +145,7 @@
00
-
+ Scene 1.3FILENOVEL
@@ -157,14 +157,14 @@
00
-
+ Chapter 2FOLDERNOVELNewFalse
-
+ Chapter 2FILENOVEL
@@ -176,7 +176,7 @@
00
-
+ Scene 2.1FILENOVEL
@@ -188,7 +188,7 @@
00
-
+ Scene 2.2FILENOVEL
@@ -200,7 +200,7 @@
00
-
+ Scene 2.3FILENOVEL
@@ -212,14 +212,14 @@
00
-
+ Chapter 3FOLDERNOVELNewFalse
-
+ Chapter 3FILENOVEL
@@ -231,7 +231,7 @@
00
-
+ Scene 3.1FILENOVEL
@@ -243,7 +243,7 @@
00
-
+ Scene 3.2FILENOVEL
@@ -255,7 +255,7 @@
00
-
+ Scene 3.3FILENOVEL
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 @@
-
+ NovelROOTNOVELNewFalse
-
+ PlotROOTPLOTNewFalse
-
+ CharactersROOTCHARACTERNewFalse
-
+ LocationsROOTWORLDNewFalse
-
+ TimelineROOTTIMELINENewFalse
-
+ ObjectsROOTOBJECTNewFalse
-
+ EntityROOTENTITYNewFalse
-
+ Title PageFILENOVEL
@@ -102,7 +102,7 @@
00
-
+ Scene 1FILENOVEL
@@ -114,7 +114,7 @@
00
-
+ Scene 2FILENOVEL
@@ -126,7 +126,7 @@
00
-
+ Scene 3FILENOVEL
@@ -138,7 +138,7 @@
00
-
+ Scene 4FILENOVEL
@@ -150,7 +150,7 @@
00
-
+ Scene 5FILENOVEL
@@ -162,7 +162,7 @@
00
-
+ Scene 6FILENOVEL
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 @@
-
+ NovelROOTNOVELNewFalse
-
+ PlotROOTPLOTNewFalse
-
+ CharactersROOTCHARACTERNewFalse
-
+ WorldROOTWORLDNewFalse
-
+ Title PageFILENOVEL
@@ -79,14 +79,14 @@
00
-
+ New ChapterFOLDERNOVELNewFalse
-
+ New ChapterFILENOVEL
@@ -98,7 +98,7 @@
00
-
+ New SceneFILENOVEL
@@ -110,7 +110,7 @@
00
-
+ HelloFILENOVEL
@@ -122,7 +122,7 @@
00
-
+ JaneFILECHARACTER
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 @@
-
+ NovelROOTNOVELNewFalse
-
+ PlotROOTPLOTNewFalse
-
+ CharactersROOTCHARACTERNewFalse
-
+ WorldROOTWORLDNewFalse
-
+ Title PageFILENOVEL
@@ -79,14 +79,14 @@
00
-
+ New ChapterFOLDERNOVELNewFalse
-
+ New ChapterFILENOVEL
@@ -98,7 +98,7 @@
00
-
+ New SceneFILENOVEL
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 @@
-
+ NovelROOTNOVELNewFalse
-
+ PlotROOTPLOTNewFalse
-
+ CharactersROOTCHARACTERNewFalse
-
+ WorldROOTWORLDNewFalse
-
+ Title PageFILENOVEL
@@ -79,14 +79,14 @@
00
-
+ New ChapterFOLDERNOVELNewFalse
-
+ New ChapterFILENOVEL
@@ -98,7 +98,7 @@
00
-
+ New SceneFILENOVEL
@@ -110,28 +110,28 @@
00
-
+ TimelineROOTTIMELINENewFalse
-
+ ObjectROOTOBJECTNewFalse
-
+ Custom1ROOTCUSTOMNewFalse
-
+ Custom2ROOTCUSTOM
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") == (
+ "