diff --git a/.travis.yml b/.travis.yml index 304d68ee..d40b1c68 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,20 +1,30 @@ -dist: xenial # required for Python >= 3.7 +os: linux +dist: xenial +services: + - xvfb language: python +cache: bundler sudo: required addons: apt: packages: - libenchant-dev + - python3-pyqt5 + - python3-pyqt5.qtsvg python: - # - "3.5" - # - "3.6" + - "3.5" - "3.7" - # - "3.8-dev" install: - pip install -r requirements.txt +# - pip install pytest-faulthandler + - pip install pytest-xvfb - pip install pytest-cov + - pip install pytest-qt - pip install codecov script: - - python -m pytest --cov=nw -v + - python -m pytest --cov=nw -m "project|core|gui" -v after_success: - codecov +after_failure: + - cat /sys/fs/cgroup/memory/memory.max_usage_in_bytes + diff --git a/nw/__init__.py b/nw/__init__.py index f9f642cd..656c32fe 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -15,7 +15,7 @@ import getopt from os import path, remove, rename from PyQt5.QtWidgets import QApplication -from nw.main import NovelWriter +from nw.gui.winmain import GuiMain from nw.config import Config __package__ = "novelWriter" @@ -78,7 +78,7 @@ def main(sysArgs): "logfile=", "version", "config=", - "headless", + "testmode", ] helpMsg = ( @@ -111,7 +111,7 @@ def main(sysArgs): toStd = True showTime = False confPath = None - showGUI = True + testMode = False debugGUI = False # Parse Options @@ -143,15 +143,15 @@ def main(sysArgs): showTime = True elif inOpt in ("--config"): confPath = inArg - elif inOpt in ("--headless"): - showGUI = False + elif inOpt in ("--testmode"): + testMode = True elif inOpt in ("-D","--debuggui"): debugLevel = logging.DEBUG debugStr = "{name:>20}:{lineno:<4d} {levelname:8} {message:}" debugGUI = True # Set Config Options - CONFIG.showGUI = showGUI + CONFIG.showGUI = not testMode CONFIG.debugGUI = debugGUI # Set Logging @@ -179,8 +179,12 @@ def main(sysArgs): CONFIG.initConfig(confPath) - nwApp = QApplication([]) - nwGUI = NovelWriter() - exit(nwApp.exec_()) + if testMode: + nwGUI = GuiMain() + return nwGUI + else: + nwApp = QApplication([]) + nwGUI = GuiMain() + exit(nwApp.exec_()) return diff --git a/nw/config.py b/nw/config.py index 254c06df..5805396b 100644 --- a/nw/config.py +++ b/nw/config.py @@ -117,7 +117,11 @@ class Config: logger.debug("Loading config file") confParser = configparser.ConfigParser() - confParser.read_file(open(path.join(self.confPath,self.confFile))) + try: + confParser.read_file(open(path.join(self.confPath,self.confFile))) + except Exception as e: + logger.error("Could not load config file") + return False # Get options @@ -182,7 +186,7 @@ class Config: if confParser.has_option(cnfSec,"recent%d" % i): self.recentList[i] = confParser.get(cnfSec,"recent%d" % i) - return + return True def saveConfig(self): @@ -266,13 +270,14 @@ class Config: return def setConfPath(self, newPath): - if newPath is None: return + if newPath is None: + return True if not path.isfile(newPath): logger.error("Config: File not found. Using default config path instead.") - return + return False self.confPath = path.dirname(newPath) self.confFile = path.basename(newPath) - return + return True def setWinSize(self, newWidth, newHeight): if abs(self.winGeometry[self.WIN_WIDTH] - newWidth) >= 10: @@ -281,14 +286,16 @@ class Config: if abs(self.winGeometry[self.WIN_HEIGHT] - newHeight) >= 10: self.winGeometry[self.WIN_HEIGHT] = newHeight self.confChanged = True - return + return True def setTreeColWidths(self, colWidths): self.treeColWidth = colWidths - return + self.confChanged = True + return True def setMainPanePos(self, panePos): self.mainPanePos = panePos - return + self.confChanged = True + return True # End Class Config diff --git a/nw/gui/doctree.py b/nw/gui/doctree.py index ef80d98a..c35d443c 100644 --- a/nw/gui/doctree.py +++ b/nw/gui/doctree.py @@ -282,20 +282,21 @@ class GuiDocTree(QTreeWidget): def propagateCount(self, tHandle, theCount, nDepth=0): tItem = self._getTreeItem(tHandle) - tItem.setText(self.C_COUNT,str(theCount)) - pItem = tItem.parent() - if pItem is not None: - pCount = 0 - for i in range(pItem.childCount()): - pCount += int(pItem.child(i).text(self.C_COUNT)) - pHandle = pItem.text(self.C_HANDLE) - if not nDepth > 200 and pHandle != "": - self.propagateCount(pHandle, pCount, nDepth+1) + if tItem is not None: + tItem.setText(self.C_COUNT,str(theCount)) + pItem = tItem.parent() + if pItem is not None: + pCount = 0 + for i in range(pItem.childCount()): + pCount += int(pItem.child(i).text(self.C_COUNT)) + pHandle = pItem.text(self.C_HANDLE) + if not nDepth > 200 and pHandle != "": + self.propagateCount(pHandle, pCount, nDepth+1) return def buildTree(self): self.clear() - for tHandle in self.theProject.projTree: + for tHandle in self.theProject.treeOrder: nwItem = self.theProject.projTree[tHandle] self._addTreeItem(nwItem) return True diff --git a/nw/gui/winmain.py b/nw/gui/winmain.py index 8b1b3357..87386287 100644 --- a/nw/gui/winmain.py +++ b/nw/gui/winmain.py @@ -170,7 +170,7 @@ class GuiMain(QMainWindow): if self.saveProject(): self.theProject.newProject() self.treeView.buildTree() - return + return True def openProject(self, projFile=None): if projFile is None: @@ -207,7 +207,7 @@ class GuiMain(QMainWindow): self.stackPane.setCurrentIndex(self.stackDoc) self.docEditor.setText(self.theDocument.openDocument(tHandle)) self.docEditor.changeWidth() - return + return True def saveDocument(self): if self.theDocument.theItem is not None: @@ -217,7 +217,7 @@ class GuiMain(QMainWindow): self.theDocument.theItem.setParaCount(self.docEditor.paraCount) self.theDocument.saveDocument(docHtml) self.docEditor.setDocumentChanged(False) - return + return True def _previewDocument(self): @@ -246,7 +246,7 @@ class GuiMain(QMainWindow): tHandle = self.treeView.getSelectedHandle() if tHandle is None: logger.warning("No item selected") - return + return False logger.verbose("Opening item %s" % tHandle) nwItem = self.theProject.getItem(tHandle) @@ -255,7 +255,8 @@ class GuiMain(QMainWindow): self.openDocument(tHandle) else: logger.verbose("Requested item %s is not a file" % tHandle) - return + + return True def editItem(self): tHandle = self.treeView.getSelectedHandle() diff --git a/nw/gui/wordcounter.py b/nw/gui/wordcounter.py index f15f5947..b44d1e60 100644 --- a/nw/gui/wordcounter.py +++ b/nw/gui/wordcounter.py @@ -78,6 +78,6 @@ class WordCounter(QThread): self.paraCount += 1 prevEmpty = countPara == False - pass + return -## END Class _WordCounter +## END Class WordCounter diff --git a/nw/main.py b/nw/main.py deleted file mode 100644 index f7a90f10..00000000 --- a/nw/main.py +++ /dev/null @@ -1,29 +0,0 @@ -# -*- coding: utf-8 -*- -"""novelWriter Main Class - - novelWriter – Main Class -========================== - Sets up the main GUI and holds action and event functions - - File History: - Created: 2018-09-22 [0.0.1] - -""" - -import logging -import nw - -from nw.gui.winmain import GuiMain - -logger = logging.getLogger(__name__) - -class NovelWriter(): - - def __init__(self): - super().__init__() - - self.winMain = GuiMain() - - return - -# END Class NovelWriter diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..17f2ac93 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +markers = + project: Project classes tests + core: Core functionality tests + gui: Qt5 GUI tests + serial diff --git a/tests/nwtools.py b/tests/nwtools.py index 34063c4f..9ae3e6ee 100644 --- a/tests/nwtools.py +++ b/tests/nwtools.py @@ -11,8 +11,18 @@ def ensureDir(theDir): return def cmpFiles(fileOne, fileTwo, ignoreLines=[]): - foOne = open(fileOne,mode="r") - foTwo = open(fileTwo,mode="r") + + try: + foOne = open(fileOne,mode="r") + except Exception as e: + print(str(e)) + return False + + try: + foTwo = open(fileTwo,mode="r") + except Exception as e: + print(str(e)) + return False txtOne = foOne.readlines() txtTwo = foTwo.readlines() diff --git a/tests/reference/gui_1489056e0916_main.nwd b/tests/reference/gui_1489056e0916_main.nwd new file mode 100644 index 00000000..2a3624ae --- /dev/null +++ b/tests/reference/gui_1489056e0916_main.nwd @@ -0,0 +1,15 @@ +# Hello World! + +## With a Subtitle + +### An Even Subier Title + +#### Basically Not a Title at All + +% How about a comment? +@keyword: value + +This is a paragraph of dummy text. + +This is another paragraph of much longer dummy text. It is in fact very very dumb dummy text! We can also try replacing “quotes”, even single’s quotes are replaced. We can hyphen-ate, make dashes – and even longer dashes — if we want. Ellipsis? Not a problem either … + diff --git a/tests/reference/gui_nwProject.nwx b/tests/reference/gui_nwProject.nwx new file mode 100644 index 00000000..80caebae --- /dev/null +++ b/tests/reference/gui_nwProject.nwx @@ -0,0 +1,58 @@ + + + + + + + + False + + + + Novel + ROOT + NOVEL + 0 + False + + + New Chapter + FOLDER + NOVEL + 0 + False + + + New Scene + FILE + NOVEL + 0 + False + SCENE + 0 + 0 + 0 + + + Characters + ROOT + CHARACTER + 0 + False + + + Plot + ROOT + PLOT + 0 + False + + + World + ROOT + WORLD + 0 + False + + + diff --git a/tests/test_common.py b/tests/test_common.py index d9694249..6e5b1e5b 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -2,10 +2,11 @@ """novelWriter Common Class Tester """ -import nw +import nw, pytest from nw.common import * from nwtools import cmpList +@pytest.mark.core def testCheckString(): assert checkString(None, "NotNone",True) is None assert checkString("None","NotNone",True) is None @@ -15,6 +16,7 @@ def testCheckString(): assert checkString(1.0, "NotNone",False) == "NotNone" assert checkString(True, "NotNone",False) == "NotNone" +@pytest.mark.core def testCheckInt(): assert checkInt(None, 3,True) is None assert checkInt("None",3,True) is None @@ -23,6 +25,7 @@ def testCheckInt(): assert checkInt(1.0, 3,False) == 1 assert checkInt(True, 3,False) == 1 +@pytest.mark.core def testCheckBool(): assert checkBool(None, 3, True) is None assert checkBool("None", 3, True) is None @@ -36,6 +39,7 @@ def testCheckBool(): assert checkBool(1.0, None, False) is None assert checkBool(2.0, None, False) is None +@pytest.mark.core def testColRange(): assert colRange([0,0], [0,0], 0) is None assert cmpList(colRange([200,50,0], [50,200,0], 1), [200,50,0]) diff --git a/tests/test_config.py b/tests/test_config.py index 90c68d8b..4301886a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,7 @@ """novelWriter Config Class Tester """ -import nw +import nw, pytest from nwtools import * from os import path, unlink from nw.config import Config @@ -20,10 +20,57 @@ ensureDir(testTemp) if path.isfile(tmpConf): unlink(tmpConf) +@pytest.mark.core def testConfigInit(): assert theConf.initConfig(testTemp) assert cmpFiles(tmpConf, refConf, [2]) + assert not theConf.confChanged +@pytest.mark.core def testConfigSave(): assert theConf.saveConfig() assert cmpFiles(tmpConf, refConf, [2]) + assert not theConf.confChanged + +@pytest.mark.core +def testConfigSetConfPath(): + assert theConf.setConfPath(None) + assert not theConf.setConfPath(path.join("somewhere","over","the","rainbow")) + assert theConf.setConfPath(path.join(testTemp,"novelwriter.conf")) + assert theConf.confPath == testTemp + assert theConf.confFile == "novelwriter.conf" + assert not theConf.confChanged + +@pytest.mark.core +def testConfigLoad(): + assert theConf.loadConfig() + assert not theConf.confChanged + +@pytest.mark.core +def testConfigSetWinSize(): + assert theConf.setWinSize(1105, 655) + assert not theConf.confChanged + assert theConf.setWinSize(70,70) + assert theConf.confChanged + assert theConf.setWinSize(1100, 650) + assert theConf.saveConfig() + assert cmpFiles(tmpConf, refConf, [2]) + assert not theConf.confChanged + +@pytest.mark.core +def testConfigSetTreeColWidths(): + assert theConf.setTreeColWidths([0, 0, 0]) + assert theConf.confChanged + assert theConf.setTreeColWidths([120, 30, 50]) + assert theConf.saveConfig() + assert cmpFiles(tmpConf, refConf, [2]) + assert not theConf.confChanged + +@pytest.mark.core +def testConfigSetMainPanePos(): + assert theConf.setMainPanePos([0, 0]) + assert theConf.confChanged + assert theConf.setMainPanePos([300, 800]) + assert theConf.saveConfig() + assert cmpFiles(tmpConf, refConf, [2]) + assert not theConf.confChanged diff --git a/tests/test_gui.py b/tests/test_gui.py new file mode 100644 index 00000000..842158cc --- /dev/null +++ b/tests/test_gui.py @@ -0,0 +1,111 @@ +# -*- coding: utf-8 -*- +"""novelWriter Main GUI Class Tester +""" + +import nw, pytest +from nwtools import * +from os import path, unlink +from PyQt5.QtCore import Qt + +keyDelay = 10 +stepDelay = 50 +testDir = path.dirname(__file__) +testRef = path.join(testDir,"reference") + +@pytest.mark.gui +def testMainWindows(qtbot, tmpdir): + confDir = str(tmpdir.mkdir("conf")) + projDir = str(tmpdir.mkdir("project")) + nwGUI = nw.main(["--testmode","--config=%s" % confDir]) + qtbot.addWidget(nwGUI) + nwGUI.show() + qtbot.waitForWindowShown(nwGUI) + qtbot.wait(stepDelay) + + # Create new, save, open project + nwGUI.theProject.handleSeed = 42 + assert nwGUI.theProject.setProjectPath(projDir) + assert nwGUI.newProject() + assert nwGUI.theProject.setProjectPath(projDir) + assert nwGUI.saveProject() + qtbot.wait(stepDelay) + assert nwGUI.openProject(projDir) + qtbot.wait(stepDelay) + + # Check that tree items have been created + assert nwGUI.treeView._getTreeItem("73475cb40a568") is not None + assert nwGUI.treeView._getTreeItem("25fc0e7096fc6") is not None + assert nwGUI.treeView._getTreeItem("31489056e0916") is not None + assert nwGUI.treeView._getTreeItem("44cb730c42048") is not None + assert nwGUI.treeView._getTreeItem("71ee45a3c0db9") is not None + assert nwGUI.treeView._getTreeItem("811786ad1ae74") is not None + + # Select the 'New Scene' file + nwGUI.treeView.setFocus() + nwGUI.treeView._getTreeItem("73475cb40a568").setExpanded(True) + nwGUI.treeView._getTreeItem("25fc0e7096fc6").setExpanded(True) + nwGUI.treeView._getTreeItem("31489056e0916").setSelected(True) + assert nwGUI.openSelectedItem() + + # Type something into the document + nwGUI.docEditor.setFocus() + for c in "# Hello World!": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + + for c in "## With a Subtitle": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + + for c in "### An Even Subier Title": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + + for c in "#### Basically Not a Title at All": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + + for c in "% How about a comment?": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + for c in "@keyword: value": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + + for c in "This is a paragraph of dummy text.": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + + for c in "This is another paragraph of much longer dummy text. It is in fact very very dumb dummy text! ": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + for c in "We can also try replacing \"quotes\", even single's quotes are replaced. ": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + for c in "We can hyphen-ate, make dashes -- and even longer dashes --- if we want. ": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + for c in "Ellipsis? Not a problem either ... ": + qtbot.keyClick(nwGUI.docEditor, c, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + qtbot.keyClick(nwGUI.docEditor, Qt.Key_Return, delay=keyDelay) + + qtbot.wait(stepDelay) + nwGUI.docEditor._runCounter() + qtbot.wait(1000) + + # Save the document + assert nwGUI.docEditor.docChanged + assert nwGUI.saveDocument() + qtbot.wait(stepDelay) + + # Check the files + projFile = path.join(projDir,"nwProject.nwx") + assert cmpFiles(projFile, path.join(testRef,"gui_nwProject.nwx"), [2]) + sceneFile = path.join(projDir,"data_3","1489056e0916_main.nwd") + assert cmpFiles(sceneFile, path.join(testRef,"gui_1489056e0916_main.nwd")) + + # qtbot.stopForInteraction() diff --git a/tests/test_project.py b/tests/test_project.py index 9aa0d2d1..14406287 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -2,8 +2,7 @@ """novelWriter Project Class Tester """ -import nw -import types +import nw, pytest, types from os import path, unlink from nwtools import * @@ -31,20 +30,24 @@ theProject.handleSeed = 42 projFile = path.join(testProj,"nwProject.nwx") +@pytest.mark.project def testProjectNew(): assert theProject.newProject() assert theProject.setProjectPath(testProj) assert theProject.saveProject() assert cmpFiles(projFile, path.join(testRef,"new_nwProject.nwx"), [2]) +@pytest.mark.project def testProjectOpen(): assert theProject.openProject(projFile) +@pytest.mark.project def testProjectSave(): assert theProject.saveProject() assert cmpFiles(projFile, path.join(testRef,"new_nwProject.nwx"), [2]) assert not theProject.projChanged +@pytest.mark.project def testProjectNewRoot(): assert theProject.openProject(projFile) assert isinstance(theProject.newRoot("Novel", nwItemClass.NOVEL), type(None))