diff --git a/nw/__init__.py b/nw/__init__.py index 02ac7162..8cb87d08 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -32,7 +32,7 @@ import logging from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QApplication, QErrorMessage -from nw.error import exceptionHandler +from nw.error import exceptionHandler, logException from nw.config import Config ## @@ -268,9 +268,9 @@ def main(sysArgs=None): bundle = NSBundle.mainBundle() info = bundle.localizedInfoDictionary() or bundle.infoDictionary() info["CFBundleName"] = "novelWriter" - except ImportError as e: + except ImportError: logger.error("Failed to set application name") - logger.error(str(e)) + logException() # Import GUI (after dependency checks), and launch from nw.guimain import GuiMain diff --git a/nw/common.py b/nw/common.py index 11546c88..826f7370 100644 --- a/nw/common.py +++ b/nw/common.py @@ -30,7 +30,9 @@ from datetime import datetime from PyQt5.QtWidgets import qApp -from nw.constants import nwConst, nwUnicode +from nw.constants import ( + nwConst, nwUnicode, nwItemClass, nwItemType, nwItemLayout +) logger = logging.getLogger(__name__) @@ -103,11 +105,39 @@ def isHandle(theString): return False if len(theString) != 13: return False - invalidChar = False for c in theString: if c not in "0123456789abcdef": - invalidChar = True - return not invalidChar + return False + return True + +def isTitleTag(theString): + """Check if a string is a valid title string. + """ + if not isinstance(theString, str): + return False + if len(theString) != 7: + return False + if not theString.startswith("T"): + return False + for c in theString[1:]: + if c not in "0123456789": + return False + return True + +def isItemClass(theString): + """Check if an item is a calid nwItemClass identifier. + """ + return theString in nwItemClass.__members__ + +def isItemType(theString): + """Check if an item is a calid nwItemType identifier. + """ + return theString in nwItemType.__members__ + +def isItemLayout(theString): + """Check if an item is a calid nwItemLayout identifier. + """ + return theString in nwItemLayout.__members__ def hexToInt(value, default=0): """Convert a hex string to an integer. diff --git a/nw/config.py b/nw/config.py index d35582fb..1a9aade2 100644 --- a/nw/config.py +++ b/nw/config.py @@ -38,6 +38,7 @@ from PyQt5.QtCore import QT_VERSION_STR, QStandardPaths, QSysInfo from nw.constants import nwConst, nwFiles, nwUnicode from nw.common import splitVersionNumber, formatTimeStamp +from nw.error import logException logger = logging.getLogger(__name__) @@ -293,7 +294,7 @@ class Config: os.mkdir(self.confPath) except Exception as e: logger.error("Could not create folder: %s" % self.confPath) - logger.error(str(e)) + logException() self.hasError = True self.errData.append("Could not create folder: %s" % self.confPath) self.errData.append(str(e)) @@ -316,7 +317,7 @@ class Config: os.mkdir(self.dataPath) except Exception as e: logger.error("Could not create folder: %s" % self.dataPath) - logger.error(str(e)) + logException() self.hasError = True self.errData.append("Could not create folder: %s" % self.dataPath) self.errData.append(str(e)) @@ -361,7 +362,7 @@ class Config: cnfParse.read_file(inFile) except Exception as e: logger.error("Could not load config file") - logger.error(str(e)) + logException() self.hasError = True self.errData.append("Could not load config file") self.errData.append(str(e)) @@ -702,7 +703,7 @@ class Config: self.confChanged = False except Exception as e: logger.error("Could not save config file") - logger.error(str(e)) + logException() self.hasError = True self.errData.append("Could not save config file") self.errData.append(str(e)) @@ -978,9 +979,9 @@ class Config: return self._unpackList( cnfParse.get(cnfSec, cnfName), cnfDefault, self.CNF_S_LST ) - except ValueError as e: + except ValueError: logger.error("Failed to load value from config file.") - logger.error(str(e)) + logException() return cnfDefault return cnfDefault diff --git a/nw/core/index.py b/nw/core/index.py index ba2e06f3..7a9c88bc 100644 --- a/nw/core/index.py +++ b/nw/core/index.py @@ -36,11 +36,13 @@ from nw.constants import ( ) from nw.core.document import NWDoc from nw.core.tools import countWords +from nw.common import isHandle, isTitleTag, isItemClass, isItemLayout logger = logging.getLogger(__name__) class NWIndex(): + H_VALID = ("H0", "H1", "H2", "H3", "H4") H_LEVEL = {"H0": 0, "H1": 1, "H2": 2, "H3": 3, "H4": 4} def __init__(self, theProject, theParent): @@ -152,9 +154,14 @@ class NWIndex(): try: with open(indexFile, mode="r", encoding="utf8") as inFile: theData = json.load(inFile) - except Exception as e: + except Exception: logger.error("Failed to load index file") - logger.error(str(e)) + nw.logException() + self.indexBroken = True + self.theParent.makeAlert( + "Could not load cached index file. Rebuilding index.", + nwAlert.WARN + ) return False self._tagIndex = theData.get("tagIndex", {}) @@ -188,9 +195,9 @@ class NWIndex(): "noteIndex" : self._noteIndex, "textCounts" : self._textCounts, }, outFile, indent=2) - except Exception as e: + except Exception: logger.error("Failed to save index file") - logger.error(str(e)) + nw.logException() return False return True @@ -200,37 +207,25 @@ class NWIndex(): elements it should. """ logger.debug("Checking index") - self.indexBroken = False + tStart = time() try: - for tTag in self._tagIndex: - if len(self._tagIndex[tTag]) != 4: - self.indexBroken = True - - for tHandle in self._refIndex: - for sTitle in self._refIndex[tHandle]: - for tEntry in self._refIndex[tHandle][sTitle]["tags"]: - if len(tEntry) != 3: - self.indexBroken = True - - for tHandle in self._novelIndex: - for sLine in self._novelIndex[tHandle]: - if len(self._novelIndex[tHandle][sLine].keys()) != 8: - self.indexBroken = True - - for tHandle in self._noteIndex: - for sLine in self._noteIndex[tHandle]: - if len(self._noteIndex[tHandle][sLine].keys()) != 8: - self.indexBroken = True - - for tHandle in self._textCounts: - if len(self._textCounts[tHandle]) != 3: - self.indexBroken = True + self._checkTagIndex() + self._checkRefIndex() + self._checkNovelNoteIndex("novelIndex") + self._checkNovelNoteIndex("noteIndex") + self._checkTextCounts() + self.indexBroken = False except Exception: + logger.error("Error while checking index") + nw.logException() self.indexBroken = True + tEnd = time() + logger.debug("Index check took %.3f ms" % ((tEnd - tStart)*1000)) logger.debug("Index check complete") + if self.indexBroken: self.clearIndex() self.theParent.makeAlert( @@ -745,4 +740,143 @@ class NWIndex(): return theHandles + ## + # Index Checkers + ## + + def _checkTagIndex(self): + """Scan the tag index for errors. + Waring: This function raises exceptions. + """ + for tTag in self._tagIndex: + if not isinstance(tTag, str): + raise KeyError("tagIndex key is not a string") + + tEntry = self._tagIndex[tTag] + if len(tEntry) != 4: + raise IndexError("tagIndex[a] expected 4 values") + if not isinstance(tEntry[0], int): + raise ValueError("tagIndex[a][0] is not an integer") + if not isHandle(tEntry[1]): + raise ValueError("tagIndex[a][1] is not a handle") + if not isItemClass(tEntry[2]): + raise ValueError("tagIndex[a][2] is not an nwItemClass") + if not isTitleTag(tEntry[3]): + raise ValueError("tagIndex[a][3] is not a title tag") + + return + + def _checkRefIndex(self): + """Scan the reference index for errors. + Waring: This function raises exceptions. + """ + for tHandle in self._refIndex: + if not isHandle(tHandle): + raise KeyError("refIndex key is not a handle") + + hEntry = self._refIndex[tHandle] + for sTitle in hEntry: + if not isTitleTag(sTitle): + raise KeyError("refIndex[a] key is not a title tag") + + sEntry = hEntry[sTitle] + if "tags" not in sEntry: + raise KeyError("refIndex[a][b] has no 'tag' key") + for tEntry in sEntry["tags"]: + if len(tEntry) != 3: + raise IndexError("refIndex[a][b][tags][i] expected 3 values") + if not isinstance(tEntry[0], int): + raise ValueError("refIndex[a][b][tags][i][0] is not an integer") + if not tEntry[1] in nwKeyWords.VALID_KEYS: + raise ValueError("refIndex[a][b][tags][i][1] is not a keyword") + if not isinstance(tEntry[2], str): + raise ValueError("refIndex[a][b][tags][i][2] is not a string") + + if "updated" not in sEntry: + raise KeyError("refIndex[a][b] has no 'updated' key") + if not isinstance(sEntry["updated"], int): + raise ValueError("%refIndex[a][b][updated] is not an integer") + + return + + def _checkNovelNoteIndex(self, idxName): + """Scan the novel or note index for errors. + Waring: This function raises exceptions. + """ + if idxName == "novelIndex": + theIndex = self._novelIndex + elif idxName == "noteIndex": + theIndex = self._noteIndex + else: + raise IndexError("Unknown index %s" % idxName) + + for tHandle in theIndex: + if not isHandle(tHandle): + raise KeyError("%s key is not a handle" % idxName) + + hEntry = theIndex[tHandle] + for sTitle in theIndex[tHandle]: + if not isTitleTag(sTitle): + raise KeyError("%s[a] key is not a title tag" % idxName) + + sEntry = hEntry[sTitle] + if len(sEntry) != 8: + raise IndexError("%s[a][b] expected 8 values" % idxName) + + if "level" not in sEntry: + raise KeyError("%s[a][b] has no 'level' key" % idxName) + if "title" not in sEntry: + raise KeyError("%s[a][b] has no 'title' key" % idxName) + if "layout" not in sEntry: + raise KeyError("%s[a][b] has no 'layout' key" % idxName) + if "synopsis" not in sEntry: + raise KeyError("%s[a][b] has no 'synopsis' key" % idxName) + if "cCount" not in sEntry: + raise KeyError("%s[a][b] has no 'cCount' key" % idxName) + if "wCount" not in sEntry: + raise KeyError("%s[a][b] has no 'wCount' key" % idxName) + if "pCount" not in sEntry: + raise KeyError("%s[a][b] has no 'pCount' key" % idxName) + if "updated" not in sEntry: + raise KeyError("%s[a][b] has no 'updated' key" % idxName) + + if not sEntry["level"] in self.H_VALID: + raise ValueError("%s[a][b][level] is not a header level" % idxName) + if not isinstance(sEntry["title"], str): + raise ValueError("%s[a][b][title] is not a string" % idxName) + if not isItemLayout(sEntry["layout"]): + raise ValueError("%s[a][b][layout] is not an nwItemLayout" % idxName) + if not isinstance(sEntry["synopsis"], str): + raise ValueError("%s[a][b][synopsis] is not a string" % idxName) + if not isinstance(sEntry["cCount"], int): + raise ValueError("%s[a][b][cCount] is not an integer" % idxName) + if not isinstance(sEntry["wCount"], int): + raise ValueError("%s[a][b][wCount] is not an integer" % idxName) + if not isinstance(sEntry["pCount"], int): + raise ValueError("%s[a][b][pCount] is not an integer" % idxName) + if not isinstance(sEntry["updated"], int): + raise ValueError("%s[a][b][updated] is not an integer" % idxName) + + return + + def _checkTextCounts(self): + """Scan the text counts index for errors. + Waring: This function raises exceptions. + """ + for tHandle in self._textCounts: + if not isHandle(tHandle): + raise KeyError("textCounts key is not a handle") + + tEntry = self._textCounts[tHandle] + if len(tEntry) != 3: + raise IndexError("textCounts[a] expected 3 values") + if not isinstance(tEntry[0], int): + raise ValueError("textCounts[a][0] is not an integer") + if not isinstance(tEntry[1], int): + raise ValueError("textCounts[a][1] is not an integer") + if not isinstance(tEntry[2], int): + raise ValueError("textCounts[a][2] is not an integer") + + return + # END Class NWIndex diff --git a/nw/core/item.py b/nw/core/item.py index e2a5a4e8..62460e7e 100644 --- a/nw/core/item.py +++ b/nw/core/item.py @@ -28,8 +28,10 @@ import logging from lxml import etree -from nw.common import checkInt, isHandle from nw.constants import nwItemType, nwItemClass, nwItemLayout +from nw.common import ( + checkInt, isHandle, isItemClass, isItemLayout, isItemType +) logger = logging.getLogger(__name__) @@ -201,7 +203,7 @@ class NWItem(): """ if isinstance(theType, nwItemType): self.itemType = theType - elif theType in nwItemType.__members__: + elif isItemType(theType): self.itemType = nwItemType[theType] else: logger.error("Unrecognised item type '%s'" % theType) @@ -214,7 +216,7 @@ class NWItem(): """ if isinstance(theClass, nwItemClass): self.itemClass = theClass - elif theClass in nwItemClass.__members__: + elif isItemClass(theClass): self.itemClass = nwItemClass[theClass] else: logger.error("Unrecognised item class '%s'" % theClass) @@ -227,7 +229,7 @@ class NWItem(): """ if isinstance(theLayout, nwItemLayout): self.itemLayout = theLayout - elif theLayout in nwItemLayout.__members__: + elif isItemLayout(theLayout): self.itemLayout = nwItemLayout[theLayout] else: logger.error("Unrecognised item layout '%s'" % theLayout) diff --git a/nw/core/options.py b/nw/core/options.py index 36da3f32..c0df1855 100644 --- a/nw/core/options.py +++ b/nw/core/options.py @@ -25,6 +25,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import nw import logging import json import os @@ -121,9 +122,9 @@ class OptionState(): try: with open(stateFile, mode="r", encoding="utf8") as inFile: theState = json.load(inFile) - except Exception as e: + except Exception: logger.error("Failed to load GUI options file") - logger.error(str(e)) + nw.logException() return False # Filter out unused variables @@ -148,9 +149,9 @@ class OptionState(): try: with open(stateFile, mode="w+", encoding="utf8") as outFile: json.dump(self.theState, outFile, indent=2) - except Exception as e: + except Exception: logger.error("Failed to save GUI options file") - logger.error(str(e)) + nw.logException() return False return True diff --git a/nw/core/project.py b/nw/core/project.py index a22f7aa9..90d3649f 100644 --- a/nw/core/project.py +++ b/nw/core/project.py @@ -1219,9 +1219,9 @@ class NWProject(): if len(theLines) != 4: return ["ERROR"] - except Exception as e: + except Exception: logger.error("Failed to read project lockfile") - logger.error(str(e)) + nw.logException() return ["ERROR"] return theLines @@ -1240,9 +1240,9 @@ class NWProject(): outFile.write("%s\n" % self.mainConf.kernelVer) outFile.write("%d\n" % time()) - except Exception as e: + except Exception: logger.error("Failed to write project lockfile") - logger.error(str(e)) + nw.logException() return False return True @@ -1257,9 +1257,9 @@ class NWProject(): if os.path.isfile(lockFile): try: os.unlink(lockFile) - except Exception as e: + except Exception: logger.error("Failed to remove project lockfile") - logger.error(str(e)) + nw.logException() return False return True @@ -1415,9 +1415,9 @@ class NWProject(): self.notesWCount, )) - except Exception as e: + except Exception: logger.error("Failed to write session stats file") - logger.error(str(e)) + nw.logException() return False return True @@ -1453,17 +1453,19 @@ class NWProject(): os.rename(theFile, newPath) logger.info("Moved file: %s" % theFile) logger.info("New location: %s" % newPath) - except Exception as e: - logger.error(str(e)) + except Exception: errList.append("Could not move: %s" % theFile) + logger.error("Could not move: %s" % theFile) + nw.logException() elif len(dataItem) == 21 and dataItem.endswith("_main.bak"): try: os.unlink(theFile) logger.info("Deleted file: %s" % theFile) - except Exception as e: - logger.error(str(e)) + except Exception: errList.append("Could not delete: %s" % theFile) + logger.error("Could not delete: %s" % theFile) + nw.logException() else: theErr = self._moveUnknownItem(theData, dataItem) @@ -1475,9 +1477,10 @@ class NWProject(): try: os.rmdir(theData) logger.info("Removed folder: %s" % theFolder) - except Exception as e: - logger.error(str(e)) + except Exception: errList.append("Failed to remove: %s" % theFolder) + logger.error("Failed to remove: %s" % theFolder) + nw.logException() return errList @@ -1495,8 +1498,9 @@ class NWProject(): try: os.rename(theSrc, theDst) logger.info("Moved to junk: %s" % theSrc) - except Exception as e: - logger.error(str(e)) + except Exception: + logger.error("Could not move item %s to junk." % theSrc) + nw.logException() return "Could not move item %s to junk." % theSrc return "" @@ -1529,8 +1533,9 @@ class NWProject(): logger.info("Deleting: %s" % rmFile) try: os.unlink(rmFile) - except Exception as e: - logger.error(str(e)) + except Exception: + logger.error("Could not delete: %s" % rmFile) + nw.logException() return False return True diff --git a/nw/core/spellcheck.py b/nw/core/spellcheck.py index 8b3807fa..8dd33bbe 100644 --- a/nw/core/spellcheck.py +++ b/nw/core/spellcheck.py @@ -72,9 +72,9 @@ class NWSpellCheck(): with open(self.projectDict, mode="a+", encoding="utf-8") as outFile: outFile.write("%s\n" % newWord) self.projDict.append(newWord) - except Exception as e: + except Exception: logger.error("Failed to add word to project word list %s" % str(self.projectDict)) - logger.error(str(e)) + nw.logException() return False return True return False @@ -123,9 +123,9 @@ class NWSpellCheck(): if len(theLine) > 0 and theLine not in self.projDict: self.projDict.append(theLine) logger.debug("Project word list contains %d words" % len(self.projDict)) - except Exception as e: + except Exception: logger.error("Failed to load project word list") - logger.error(str(e)) + nw.logException() return False return True @@ -201,9 +201,9 @@ class NWSpellEnchant(NWSpellCheck): try: spTag = self.theDict.tag spName = self.theDict.provider.name - except Exception as e: + except Exception: logger.error("Failed to extract information about the dictionary") - logger.error(str(e)) + nw.logException() spTag = "" spName = "" @@ -261,9 +261,9 @@ class NWSpellSimple(NWSpellCheck): logger.debug("Spell check word list for language %s loaded" % theLang) logger.debug("Word list contains %d words" % len(self.WORDS)) self.spellLanguage = theLang - except Exception as e: + except Exception: logger.error("Failed to load spell check word list for language %s" % theLang) - logger.error(str(e)) + nw.logException() self.spellLanguage = None self._readProjectDictionary(projectDict) diff --git a/nw/core/tree.py b/nw/core/tree.py index 36a56e5c..94e9b97a 100644 --- a/nw/core/tree.py +++ b/nw/core/tree.py @@ -24,6 +24,7 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import nw import logging import os @@ -179,8 +180,9 @@ class NWTree(): outFile.write("\n".join(tocList)) outFile.write("\n") - except Exception as e: - logger.error(str(e)) + except Exception: + logger.error("Could not write ToC file") + nw.logException() return False return True diff --git a/nw/error.py b/nw/error.py index 1f879e37..998b7347 100644 --- a/nw/error.py +++ b/nw/error.py @@ -24,12 +24,31 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ +import sys +import logging + from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( qApp, QDialog, QGridLayout, QStyle, QPlainTextEdit, QLabel, QDialogButtonBox ) +logger = logging.getLogger(__name__) + +# =============================================================================================== # +# Utility Functions +# =============================================================================================== # + +def logException(): + """Log the content of an exception message. + """ + exType, exValue, _ = sys.exc_info() + logger.error("%s: %s" % (exType.__name__, str(exValue).strip("'"))) + +# =============================================================================================== # +# Error Handler +# =============================================================================================== # + class NWErrorMessage(QDialog): def __init__(self, parent): @@ -72,7 +91,6 @@ class NWErrorMessage(QDialog): """Generate a message and append session data, error info and error traceback. """ - import sys from traceback import format_tb from nw import __issuesurl__, __version__ from PyQt5.Qt import PYQT_VERSION_STR @@ -133,15 +151,12 @@ class NWErrorMessage(QDialog): # END Class NWErrorMessage - def exceptionHandler(exType, exValue, exTrace): """Function to catch unhandled global exceptions. """ - import logging from traceback import print_tb from PyQt5.QtWidgets import qApp - logger = logging.getLogger(__name__) logger.critical("%s: %s" % (exType.__name__, str(exValue))) print_tb(exTrace) diff --git a/nw/gui/build.py b/nw/gui/build.py index 5bcea455..18bb4dc7 100644 --- a/nw/gui/build.py +++ b/nw/gui/build.py @@ -671,9 +671,9 @@ class GuiBuildNovel(QDialog): bldObj.doConvert() bldObj.doPostProcessing() - except Exception as e: + except Exception: logger.error("Failed to generate html of document '%s'" % tItem.itemHandle) - logger.error(str(e)) + nw.logException() if isPreview: self.docView.setText(( "Failed to generate preview. " @@ -997,9 +997,9 @@ class GuiBuildNovel(QDialog): with open(buildCache, mode="r", encoding="utf8") as inFile: theJson = inFile.read() theData = json.loads(theJson) - except Exception as e: + except Exception: logger.error("Failed to load build cache") - logger.error(str(e)) + nw.logException() return False if "htmlText" in theData.keys(): @@ -1026,9 +1026,9 @@ class GuiBuildNovel(QDialog): "htmlStyle" : self.htmlStyle, "buildTime" : self.buildTime, }, indent=2)) - except Exception as e: + except Exception: logger.error("Failed to save build cache") - logger.error(str(e)) + nw.logException() return False return True diff --git a/nw/gui/docviewer.py b/nw/gui/docviewer.py index 55e458bb..24d53019 100644 --- a/nw/gui/docviewer.py +++ b/nw/gui/docviewer.py @@ -180,9 +180,9 @@ class GuiDocViewer(QTextBrowser): aDoc.tokenizeText() aDoc.doConvert() aDoc.doPostProcessing() - except Exception as e: + except Exception: logger.error("Failed to generate preview for document with handle '%s'" % tHandle) - logger.error(str(e)) + nw.logException() self.setText("An error occurred while generating the preview.") return False diff --git a/nw/gui/theme.py b/nw/gui/theme.py index 8aa0c1fe..73d8a7b0 100644 --- a/nw/gui/theme.py +++ b/nw/gui/theme.py @@ -265,9 +265,9 @@ class GuiTheme: if os.path.isfile(self.cssFile): with open(self.cssFile, mode="r", encoding="utf8") as inFile: cssData = inFile.read() - except Exception as e: + except Exception: logger.error("Could not load theme css file") - logger.error(str(e)) + nw.logException() return False # Config File @@ -275,9 +275,9 @@ class GuiTheme: try: with open(self.confFile, mode="r", encoding="utf8") as inFile: confParser.read_file(inFile) - except Exception as e: + except Exception: logger.error("Could not load theme settings from: %s" % self.confFile) - logger.error(str(e)) + nw.logException() return False ## Main @@ -333,9 +333,9 @@ class GuiTheme: try: with open(self.syntaxFile, mode="r", encoding="utf8") as inFile: confParser.read_file(inFile) - except Exception as e: + except Exception: logger.error("Could not load syntax colours from: %s" % self.syntaxFile) - logger.error(str(e)) + nw.logException() return False ## Main @@ -637,9 +637,9 @@ class GuiIcons: try: with open(self.confFile, mode="r", encoding="utf8") as inFile: confParser.read_file(inFile) - except Exception as e: + except Exception: logger.error("Could not load icon theme settings from: %s" % self.confFile) - logger.error(str(e)) + nw.logException() return False ## Main diff --git a/tests/test_base/test_base_common.py b/tests/test_base/test_base_common.py index 79b6562a..4b076a64 100644 --- a/tests/test_base/test_base_common.py +++ b/tests/test_base/test_base_common.py @@ -26,7 +26,8 @@ import pytest from nw.common import ( checkString, checkBool, checkInt, colRange, formatInt, transferCase, fuzzyTime, checkHandle, formatTimeStamp, formatTime, hexToInt, - makeFileNameSafe + makeFileNameSafe, isHandle, isTitleTag, isItemClass, isItemType, + isItemLayout ) from tools import cmpList @@ -88,6 +89,95 @@ def testBaseCommon_CheckHandle(): # END Test testBaseCommon_CheckHandle +@pytest.mark.base +def testBaseCommon_IsHandle(): + """Test the isHandle function. + """ + assert isHandle("47666c91c7ccf") + + assert not isHandle("47666C91C7CCF") + assert not isHandle("h7666c91c7ccf") + assert not isHandle("None") + assert not isHandle(None) + assert not isHandle("STUFF") + +# END Test testBaseCommon_IsHandle + +@pytest.mark.base +def testBaseCommon_IsTitleTag(): + """Test the isItemClass function. + """ + assert isTitleTag("T123456") + + assert not isTitleTag("t123456") + assert not isTitleTag("S123456") + assert not isTitleTag("T12345A") + assert not isTitleTag("T1234567") + + assert not isTitleTag("None") + assert not isTitleTag(None) + assert not isTitleTag("STUFF") + +# END Test testBaseCommon_IsTitleTag + +@pytest.mark.base +def testBaseCommon_IsItemClass(): + """Test the isItemClass function. + """ + assert isItemClass("NO_CLASS") + assert isItemClass("NOVEL") + assert isItemClass("PLOT") + assert isItemClass("CHARACTER") + assert isItemClass("WORLD") + assert isItemClass("TIMELINE") + assert isItemClass("OBJECT") + assert isItemClass("ENTITY") + assert isItemClass("CUSTOM") + assert isItemClass("ARCHIVE") + assert isItemClass("TRASH") + + assert not isItemClass("None") + assert not isItemClass(None) + assert not isItemClass("STUFF") + +# END Test testBaseCommon_IsItemClass + +@pytest.mark.base +def testBaseCommon_IsItemType(): + """Test the isItemType function. + """ + assert isItemType("NO_TYPE") + assert isItemType("ROOT") + assert isItemType("FOLDER") + assert isItemType("FILE") + assert isItemType("TRASH") + + assert not isItemType("None") + assert not isItemType(None) + assert not isItemType("STUFF") + +# END Test testBaseCommon_IsItemType + +@pytest.mark.base +def testBaseCommon_IsItemLayout(): + """Test the isItemLayout function. + """ + assert isItemLayout("NO_LAYOUT") + assert isItemLayout("TITLE") + assert isItemLayout("BOOK") + assert isItemLayout("PAGE") + assert isItemLayout("PARTITION") + assert isItemLayout("UNNUMBERED") + assert isItemLayout("CHAPTER") + assert isItemLayout("SCENE") + assert isItemLayout("NOTE") + + assert not isItemLayout("None") + assert not isItemLayout(None) + assert not isItemLayout("STUFF") + +# END Test testBaseCommon_IsItemLayout + @pytest.mark.base def testBaseCommon_HexToInt(): """Test the hexToInt function. diff --git a/tests/test_core/test_core_index.py b/tests/test_core/test_core_index.py index e10000d6..5e1e9223 100644 --- a/tests/test_core/test_core_index.py +++ b/tests/test_core/test_core_index.py @@ -115,38 +115,7 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, outDir, refDir): # Break the index and check that we notice assert not theIndex.indexBroken - theIndex._tagIndex["Bod"].append("Stuff") # No longer len() == 4 - theIndex.checkIndex() - assert theIndex.indexBroken - - assert theIndex.loadIndex() - assert not theIndex.indexBroken - theIndex._refIndex["fb609cd8319dc"]["T000001"]["tags"].append("Stuff") # No longer len() == 3 - theIndex.checkIndex() - assert theIndex.indexBroken - - assert theIndex.loadIndex() - assert not theIndex.indexBroken - theIndex._novelIndex["7a992350f3eb6"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8 - theIndex.checkIndex() - assert theIndex.indexBroken - - assert theIndex.loadIndex() - assert not theIndex.indexBroken - theIndex._noteIndex["4c4f28287af27"]["T000001"]["Stuff"] = "" # No longer len(keys()) == 8 - theIndex.checkIndex() - assert theIndex.indexBroken - - assert theIndex.loadIndex() - assert not theIndex.indexBroken - theIndex._textCounts["7a992350f3eb6"].append("Stuff") # No longer len() == 3 - theIndex.checkIndex() - assert theIndex.indexBroken - - # Make the try/except trigger as well - assert theIndex.loadIndex() - assert not theIndex.indexBroken - theIndex._refIndex["fb609cd8319dc"]["T000001"] = {"tagssss": []} # Wrong key name + theIndex._tagIndex["Bod"].append("Stuff") theIndex.checkIndex() assert theIndex.indexBroken @@ -676,3 +645,503 @@ def testCoreIndex_ExtractData(nwMinimal, dummyGUI): assert theProject.closeProject() # END Test testCoreIndex_ExtractData + +@pytest.mark.core +def testCoreIndex_CheckTagIndex(dummyGUI): + """Test the tag index checker. + """ + theProject = NWProject(dummyGUI) + theIndex = NWIndex(theProject, dummyGUI) + + # Valid Index + theIndex._tagIndex = { + "John": [3, "14298de4d9524", "CHARACTER", "T000001"], + "Jane": [3, "bb2c23b3c42cc", "CHARACTER", "T000001"], + } + assert theIndex._checkTagIndex() is None + + # Wrong Key Type + theIndex._tagIndex = { + "John": [3, "14298de4d9524", "CHARACTER", "T000001"], + 123456: [3, "bb2c23b3c42cc", "CHARACTER", "T000001"], + } + with pytest.raises(KeyError): + theIndex._checkTagIndex() + + # Wrong Length + theIndex._tagIndex = { + "John": [3, "14298de4d9524", "CHARACTER", "T000001"], + "Jane": [3, "bb2c23b3c42cc", "CHARACTER", "T000001", "Stuff"], + } + with pytest.raises(IndexError): + theIndex._checkTagIndex() + + # Wrong Type of Entry 0 + theIndex._tagIndex = { + "John": [3, "14298de4d9524", "CHARACTER", "T000001"], + "Jane": ["3", "bb2c23b3c42cc", "CHARACTER", "T000001"], + } + with pytest.raises(ValueError): + theIndex._checkTagIndex() + + # Wrong Type of Entry 1 + theIndex._tagIndex = { + "John": [3, "14298de4d9524", "CHARACTER", "T000001"], + "Jane": [3, 0xbb2c23b3c42cc, "CHARACTER", "T000001"], + } + with pytest.raises(ValueError): + theIndex._checkTagIndex() + + # Wrong Type of Entry 2 + theIndex._tagIndex = { + "John": [3, "14298de4d9524", "CHARACTER", "T000001"], + "Jane": [3, "bb2c23b3c42cc", "INVALID_CLASS", "T000001"], + } + with pytest.raises(ValueError): + theIndex._checkTagIndex() + + # Wrong Type of Entry 3 + theIndex._tagIndex = { + "John": [3, "14298de4d9524", "CHARACTER", "T000001"], + "Jane": [3, "bb2c23b3c42cc", "CHARACTER", "INVALID"], + } + with pytest.raises(ValueError): + theIndex._checkTagIndex() + +# END Test testCoreIndex_CheckTagIndex + +@pytest.mark.core +def testCoreIndex_CheckRefIndex(dummyGUI): + """Test the reference index checker. + """ + theProject = NWProject(dummyGUI) + theIndex = NWIndex(theProject, dummyGUI) + + # Valid Index + theIndex._refIndex = { + "6a2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "T000001": {"tags": [ + [3, "@pov", "Jane"], [4, "@location", "Earth"] + ], "updated": 1611922868} + } + } + assert theIndex._checkRefIndex() is None + + # Invalid Handle + theIndex._refIndex = { + "Ha2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "T000001": {"tags": [ + [3, "@pov", "Jane"], [4, "@location", "Earth"] + ], "updated": 1611922868} + } + } + with pytest.raises(KeyError): + theIndex._checkRefIndex() + + # Invalid Title + theIndex._refIndex = { + "6a2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "INVALID": {"tags": [ + [3, "@pov", "Jane"], [4, "@location", "Earth"] + ], "updated": 1611922868} + } + } + with pytest.raises(KeyError): + theIndex._checkRefIndex() + + # Missing 'tags' + theIndex._refIndex = { + "6a2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "T000001": {"updated": 1611922868} + } + } + with pytest.raises(KeyError): + theIndex._checkRefIndex() + + # Wrong Length of 'tags' + theIndex._refIndex = { + "6a2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "T000001": {"tags": [ + [3, "@pov", "Jane"], [4, "@location", "Earth", "Stuff"] + ], "updated": 1611922868} + } + } + with pytest.raises(IndexError): + theIndex._checkRefIndex() + + # Wrong Type of 'tags' Entry 0 + theIndex._refIndex = { + "6a2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "T000001": {"tags": [ + [3, "@pov", "Jane"], ["4", "@location", "Earth"] + ], "updated": 1611922868} + } + } + with pytest.raises(ValueError): + theIndex._checkRefIndex() + + # Wrong Type of 'tags' Entry 1 + theIndex._refIndex = { + "6a2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "T000001": {"tags": [ + [3, "@pov", "Jane"], [4, "@stuff", "Earth"] + ], "updated": 1611922868} + } + } + with pytest.raises(ValueError): + theIndex._checkRefIndex() + + # Wrong Type of 'tags' Entry 1 + theIndex._refIndex = { + "6a2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "T000001": {"tags": [ + [3, "@pov", "Jane"], [4, "@location", 123456] + ], "updated": 1611922868} + } + } + with pytest.raises(ValueError): + theIndex._checkRefIndex() + + # Missing 'updated' + theIndex._refIndex = { + "6a2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "T000001": {"tags": []} + } + } + with pytest.raises(KeyError): + theIndex._checkRefIndex() + + # Wrong Type of 'updated' Entry 1 + theIndex._refIndex = { + "6a2d6d5f4f401": { + "T000000": {"tags": [], "updated": 1611922868}, + "T000001": {"tags": [], "updated": "1611922868"} + } + } + with pytest.raises(ValueError): + theIndex._checkRefIndex() + +# END Test testCoreIndex_CheckRefIndex + +@pytest.mark.core +def testCoreIndex_CheckNovelNoteIndex(dummyGUI): + """Test the novel and note index checkers. + """ + theProject = NWProject(dummyGUI) + theIndex = NWIndex(theProject, dummyGUI) + + # Valid Index + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + theIndex._noteIndex = theIndex._novelIndex.copy() + assert theIndex._checkNovelNoteIndex("novelIndex") is None + assert theIndex._checkNovelNoteIndex("noteIndex") is None + with pytest.raises(IndexError): + theIndex._checkNovelNoteIndex("notAnIndex") + + # Invalid Handle + theIndex._novelIndex = { + "H3b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Invalid Title + theIndex._novelIndex = { + "53b69b83cdafc": { + "INVALID": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Wrong Length + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868, "stuff": None + } + } + } + with pytest.raises(IndexError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Missing Keys + # ============ + + # Missing 'level' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "stuff": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Missing 'title' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "stuff": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Missing 'layout' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "stuff": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Missing 'synopsis' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "stuff": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Missing 'cCount' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "stuff": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Missing 'wCount' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "stuff": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Missing 'pCount' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "stuff": 2, "updated": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Missing 'updated' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "stuff": 1611922868 + } + } + } + with pytest.raises(KeyError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Wrong Types + # =========== + + # Wrong Type for 'level' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "XX", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(ValueError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Wrong Type for 'title' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": 12345678, "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(ValueError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Wrong Type for 'layout' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "INVALID", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(ValueError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Wrong Type for 'synopsis' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": 123456, + "cCount": 72, "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(ValueError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Wrong Type for 'cCount' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": "72", "wCount": 15, "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(ValueError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Wrong Type for 'wCount' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": "15", "pCount": 2, "updated": 1611922868 + } + } + } + with pytest.raises(ValueError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Wrong Type for 'pCount' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": "2", "updated": 1611922868 + } + } + } + with pytest.raises(ValueError): + theIndex._checkNovelNoteIndex("novelIndex") + + # Wrong Type for 'updated' + theIndex._novelIndex = { + "53b69b83cdafc": { + "T000001": { + "level": "H1", "title": "My Novel", "layout": "TITLE", "synopsis": "text", + "cCount": 72, "wCount": 15, "pCount": 2, "updated": "1611922868" + } + } + } + with pytest.raises(ValueError): + theIndex._checkNovelNoteIndex("novelIndex") + +# END Test testCoreIndex_CheckNovelNoteIndex + +@pytest.mark.core +def testCoreIndex_CheckTextCounts(dummyGUI): + """Test the text counts checker. + """ + theProject = NWProject(dummyGUI) + theIndex = NWIndex(theProject, dummyGUI) + + # Valid Index + theIndex._textCounts = { + "53b69b83cdafc": [72, 15, 2], + "974e400180a99": [210, 40, 2], + } + assert theIndex._checkTextCounts() is None + + # Invalid Handle + theIndex._textCounts = { + "53b69b83cdafc": [72, 15, 2], + "h74e400180a99": [210, 40, 2], + } + with pytest.raises(KeyError): + theIndex._checkTextCounts() + + # Wrong Length + theIndex._textCounts = { + "53b69b83cdafc": [72, 15, 2], + "974e400180a99": [210, 40, 2, 8], + } + with pytest.raises(IndexError): + theIndex._checkTextCounts() + + # Type of Entry 0 + theIndex._textCounts = { + "53b69b83cdafc": [72, 15, 2], + "974e400180a99": ["210", 40, 2], + } + with pytest.raises(ValueError): + theIndex._checkTextCounts() + + # Type of Entry 1 + theIndex._textCounts = { + "53b69b83cdafc": [72, 15, 2], + "974e400180a99": [210, "40", 2], + } + with pytest.raises(ValueError): + theIndex._checkTextCounts() + + # Type of Entry 2 + theIndex._textCounts = { + "53b69b83cdafc": [72, 15, 2], + "974e400180a99": [210, 40, "2"], + } + with pytest.raises(ValueError): + theIndex._checkTextCounts() + +# END Test testCoreIndex_CheckTextCounts