From 41bbd7b2591927b43e0bdbb240de03132977dcc3 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Mon, 23 Nov 2020 12:48:08 +0100 Subject: [PATCH 01/52] Added releases URL --- nw/__init__.py | 1 + nw/gui/mainmenu.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/nw/__init__.py b/nw/__init__.py index 670351b9..a5960f30 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -70,6 +70,7 @@ __status__ = "Beta" __url__ = "https://novelwriter.io" __sourceurl__ = "https://github.com/vkbo/novelWriter" __issuesurl__ = "https://github.com/vkbo/novelWriter/issues" +__releaseurl__ = "https://github.com/vkbo/novelWriter/releases/latest" __domain__ = "novelwriter.io" __docurl__ = "https://novelwriter.readthedocs.io" __credits__ = [ diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index c64debe6..d6a1e411 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -918,16 +918,22 @@ class GuiMainMenu(QMenuBar): # Document > Go to Website self.aWebsite = QAction("Open the novelWriter Website", self) - self.aWebsite.setStatusTip("View the main website") + self.aWebsite.setStatusTip("Open the main website at %s" % nw.__url__) self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__)) self.helpMenu.addAction(self.aWebsite) # Document > Report Issue - self.aIssue = QAction("Report an Issue", self) - self.aIssue.setStatusTip("Report a bug or issue on GitHub") + 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 > Latest Release + self.aIssue = QAction("Latest Release (GitHub)", self) + self.aIssue.setStatusTip("Open the Releases page on GitHub at %s" % nw.__releaseurl__) + self.aIssue.triggered.connect(lambda: self._openWebsite(nw.__releaseurl__)) + self.helpMenu.addAction(self.aIssue) + return # END Class GuiMainMenu From 12c2d93968d8f8d2881e2bc50c26aa5a6836557e Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Mon, 23 Nov 2020 13:05:53 +0100 Subject: [PATCH 02/52] Added release notes from website --- CHANGELOG.md | 2 +- nw/assets/text/release_notes_en.htm | 34 +++++++++++++++++++++++++++++ nw/gui/about.py | 20 +++++++++++++++++ nw/gui/mainmenu.py | 4 ++-- 4 files changed, 57 insertions(+), 3 deletions(-) create mode 100644 nw/assets/text/release_notes_en.htm diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8b991a..6687543d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# novelWriter ChangeLog +# novelWriter Change Log ## Version 1.0 Release Candidate 1 [2020-11-16] diff --git a/nw/assets/text/release_notes_en.htm b/nw/assets/text/release_notes_en.htm new file mode 100644 index 00000000..ac186481 --- /dev/null +++ b/nw/assets/text/release_notes_en.htm @@ -0,0 +1,34 @@ + + + +

Release 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. No serious +bugs have been reported or encountered, and I feel it’s time to move on to the release candidates. +Most of the minor changes should not be noticeable to most users. However, there are a couple of +noticeable changes.

+

Typewriter Mode

+

"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 backspace). +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. It makes it more flexible. 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 colour. The new settings would not be applied until the application was shut +down and started again. This makes it a bit tedious to look through themes to find the one you +want. This issue has now been resolved.

+

Switching main GUI theme still requires a restart.

+

The full changelog is available +here.

+ + diff --git a/nw/gui/about.py b/nw/gui/about.py index 7f00cf11..d2c436cf 100644 --- a/nw/gui/about.py +++ b/nw/gui/about.py @@ -84,10 +84,15 @@ class GuiAbout(QDialog): self.pageLicense.setOpenExternalLinks(True) self.pageLicense.document().setDocumentMargin(self.mainConf.pxInt(16)) + self.pageNotes = QTextBrowser() + self.pageNotes.setOpenExternalLinks(True) + self.pageNotes.document().setDocumentMargin(self.mainConf.pxInt(16)) + # Main Tab Area self.tabBox = QTabWidget() self.tabBox.addTab(self.pageAbout, "About") self.tabBox.addTab(self.pageLicense, "License") + self.tabBox.addTab(self.pageNotes, "Release Notes") self.innerBox.addWidget(self.tabBox) # OK Button @@ -101,6 +106,7 @@ class GuiAbout(QDialog): self._setStyleSheet() self._fillAboutPage() self._fillLicensePage() + self._fillNotesPage() logger.debug("GuiAbout initialisation complete") @@ -205,6 +211,19 @@ class GuiAbout(QDialog): self.pageLicense.setHtml("Error loading license text ...") return + def _fillNotesPage(self): + """Load the content for the Release Notes page. + """ + docName = "release_notes_%s.htm" % self.mainConf.guiLang + docPath = os.path.join(self.mainConf.assetPath, "text", docName) + 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 release notes text ...") + return + def _setStyleSheet(self): """Set stylesheet for all browser tabs """ @@ -222,6 +241,7 @@ class GuiAbout(QDialog): ) self.pageAbout.document().setDefaultStyleSheet(styleSheet) self.pageLicense.document().setDefaultStyleSheet(styleSheet) + self.pageNotes.document().setDefaultStyleSheet(styleSheet) return diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index d6a1e411..9bf4cae1 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") From 00f6277eb7c0b3cde121a54bc898209d20517bbd Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 24 Nov 2020 18:36:08 +0100 Subject: [PATCH 03/52] Add config option and set up menu action --- ...release_notes_en.htm => release_notes.htm} | 14 ++-- nw/config.py | 5 ++ nw/gui/about.py | 65 +++++++++++-------- nw/guimain.py | 17 ++++- 4 files changed, 65 insertions(+), 36 deletions(-) rename nw/assets/text/{release_notes_en.htm => release_notes.htm} (76%) diff --git a/nw/assets/text/release_notes_en.htm b/nw/assets/text/release_notes.htm similarity index 76% rename from nw/assets/text/release_notes_en.htm rename to nw/assets/text/release_notes.htm index ac186481..fe2a9a62 100644 --- a/nw/assets/text/release_notes_en.htm +++ b/nw/assets/text/release_notes.htm @@ -1,7 +1,7 @@ -

Release 1.0 RC1

+

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 @@ -10,12 +10,12 @@ bugs have been reported or encountered, and I feel it’s time to move on to the Most of the minor changes should not be noticeable to most users. However, there are a couple of noticeable changes.

Typewriter Mode

-

"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 backspace). -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.

+

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 +backspace). 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 diff --git a/nw/config.py b/nw/config.py index 05345b45..04c1a4db 100644 --- a/nw/config.py +++ b/nw/config.py @@ -91,6 +91,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] @@ -380,6 +381,9 @@ 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" @@ -584,6 +588,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" diff --git a/nw/gui/about.py b/nw/gui/about.py index d2c436cf..99a4d296 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,19 +81,19 @@ class GuiAbout(QDialog): self.pageAbout.setOpenExternalLinks(True) self.pageAbout.document().setDocumentMargin(self.mainConf.pxInt(16)) - self.pageLicense = QTextBrowser() - self.pageLicense.setOpenExternalLinks(True) - self.pageLicense.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)) + # 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.tabBox.addTab(self.pageNotes, "Release Notes") self.innerBox.addWidget(self.tabBox) # OK Button @@ -103,15 +104,27 @@ class GuiAbout(QDialog): self.outerBox.addWidget(self.buttonBox) self.setLayout(self.outerBox) - self._setStyleSheet() - self._fillAboutPage() - self._fillLicensePage() - self._fillNotesPage() - 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 ## @@ -198,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() @@ -211,19 +235,6 @@ class GuiAbout(QDialog): self.pageLicense.setHtml("Error loading license text ...") return - def _fillNotesPage(self): - """Load the content for the Release Notes page. - """ - docName = "release_notes_%s.htm" % self.mainConf.guiLang - docPath = os.path.join(self.mainConf.assetPath, "text", docName) - 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 release notes text ...") - return - def _setStyleSheet(self): """Set stylesheet for all browser tabs """ @@ -240,8 +251,8 @@ class GuiAbout(QDialog): hColB = self.theParent.theTheme.colHead[2], ) self.pageAbout.document().setDefaultStyleSheet(styleSheet) - self.pageLicense.document().setDefaultStyleSheet(styleSheet) self.pageNotes.document().setDefaultStyleSheet(styleSheet) + self.pageLicense.document().setDefaultStyleSheet(styleSheet) return diff --git a/nw/guimain.py b/nw/guimain.py index b4ff651a..801de5af 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -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): From 1510a5af748103ca200ac818d24e7a83a1f55421 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 24 Nov 2020 18:36:30 +0100 Subject: [PATCH 04/52] Fix and update tests --- tests/reference/novelwriter.conf | 1 + tests/reference/novelwriter_prefs.conf | 1 + tests/test_config.py | 10 +++++----- tests/test_dialogs.py | 13 +++++++++++-- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/reference/novelwriter.conf b/tests/reference/novelwriter.conf index 99b01f75..5ff6f852 100644 --- a/tests/reference/novelwriter.conf +++ b/tests/reference/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/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_config.py b/tests/test_config.py index f9ce767b..5d72cdfd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -14,7 +14,7 @@ def testConfigCore(tmpConf, nwTemp, nwRef): assert tmpConf.confPath == nwTemp assert tmpConf.saveConfig() - assert cmpFiles(testConf, refConf, [2]) + assert cmpFiles(testConf, refConf, [2, 9]) assert not tmpConf.confChanged assert tmpConf.loadConfig() @@ -51,7 +51,7 @@ def testConfigSetWinSize(tmpConf, nwTemp, nwRef): assert tmpConf.setWinSize(1200, 650) assert tmpConf.saveConfig() - assert cmpFiles(testConf, refConf, [2]) + assert cmpFiles(testConf, refConf, [2, 9]) assert not tmpConf.confChanged @pytest.mark.core @@ -73,7 +73,7 @@ def testConfigSetTreeColWidths(tmpConf, nwTemp, nwRef): assert tmpConf.confChanged assert tmpConf.saveConfig() - assert cmpFiles(testConf, refConf, [2]) + assert cmpFiles(testConf, refConf, [2, 9]) assert not tmpConf.confChanged @pytest.mark.core @@ -109,7 +109,7 @@ def testConfigSetPanePos(tmpConf, nwTemp, nwRef): assert tmpConf.confChanged assert tmpConf.saveConfig() - assert cmpFiles(testConf, refConf, [2]) + assert cmpFiles(testConf, refConf, [2, 9]) assert not tmpConf.confChanged @pytest.mark.core @@ -133,7 +133,7 @@ def testConfigFlags(tmpConf, nwTemp, nwRef): assert tmpConf.confChanged assert tmpConf.saveConfig() - assert cmpFiles(testConf, refConf, [2]) + assert cmpFiles(testConf, refConf, [2, 9]) assert not tmpConf.confChanged @pytest.mark.core diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index 81c1fc89..36921eb4 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -374,12 +374,20 @@ def testAboutBox(qtbot, monkeypatch, nwFuncTemp, nwTemp): msgAbout.show() assert msgAbout.pageAbout.document().characterCount() > 100 + assert msgAbout.pageNotes.document().characterCount() > 100 assert msgAbout.pageLicense.document().characterCount() > 100 msgAbout.mainConf.guiLang = "whatever" + + msgAbout._fillNotesPage() + assert msgAbout.pageNotes.toPlainText() == "Error loading release notes text ..." + msgAbout._fillLicensePage() assert msgAbout.pageLicense.toPlainText() == "Error loading license text ..." + msgAbout.showReleaseNotes() + assert msgAbout.tabBox.currentWidget() == msgAbout.pageNotes + # Qt About monkeypatch.setattr(QMessageBox, "aboutQt", lambda *args, **kwargs: None) nwGUI.mainMenu.aAboutQt.activate(QAction.Trigger) @@ -1201,8 +1209,9 @@ def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp, nwRef, tmpC copyfile(projConf, testConf) ignoreLines = [ 2, # Timestamp - 11, 12, 13, 14, 15, 16, 17, # Window sizes - 7, 27, # Fonts (depends on system default) + 9, # Release Notes + 12, 13, 14, 15, 16, 17, 18, # Window sizes + 7, 28, # Fonts (depends on system default) ] assert cmpFiles(testConf, refConf, ignoreLines) From 434a0c60560b330b3b0d401c4d967e26ceead2ae Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 24 Nov 2020 18:48:32 +0100 Subject: [PATCH 05/52] Updated release notes --- nw/assets/text/release_notes.htm | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/nw/assets/text/release_notes.htm b/nw/assets/text/release_notes.htm index fe2a9a62..d9818aa9 100644 --- a/nw/assets/text/release_notes.htm +++ b/nw/assets/text/release_notes.htm @@ -4,29 +4,30 @@

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. No serious -bugs have been reported or encountered, and I feel it’s time to move on to the release candidates. -Most of the minor changes should not be noticeable to most users. However, there are a couple of -noticeable changes.

+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 -backspace). This is similar to the way a typewriter scrolls the paper when hitting the return key. +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. It makes it more flexible. The feature can be controlled from -the main Preferences.

+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 colour. The new settings would not be applied until the application was shut -down and started again. This makes it a bit tedious to look through themes to find the one you -want. This issue has now been resolved.

+background and text colours. The new settings would not be fully applied until the application was +shut down and started again, thus making it a bit tedious to look through syntax themes to find the +one you want.

Switching main GUI theme still requires a restart.

The full changelog is available here.

From b28556cd40536076a34916a6473773bdb0eb6543 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 24 Nov 2020 18:51:20 +0100 Subject: [PATCH 06/52] Fix broken test --- tests/test_dialogs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index 36921eb4..4c1f1885 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -377,7 +377,7 @@ def testAboutBox(qtbot, monkeypatch, nwFuncTemp, nwTemp): assert msgAbout.pageNotes.document().characterCount() > 100 assert msgAbout.pageLicense.document().characterCount() > 100 - msgAbout.mainConf.guiLang = "whatever" + msgAbout.mainConf.assetPath = "whatever" msgAbout._fillNotesPage() assert msgAbout.pageNotes.toPlainText() == "Error loading release notes text ..." From 36db4b5d5902e2b8fcc44206cabdab6bdf88d967 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 24 Nov 2020 19:13:31 +0100 Subject: [PATCH 07/52] A few minor updates to the main menu --- nw/__init__.py | 1 + nw/gui/mainmenu.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/nw/__init__.py b/nw/__init__.py index 670351b9..a5960f30 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -70,6 +70,7 @@ __status__ = "Beta" __url__ = "https://novelwriter.io" __sourceurl__ = "https://github.com/vkbo/novelWriter" __issuesurl__ = "https://github.com/vkbo/novelWriter/issues" +__releaseurl__ = "https://github.com/vkbo/novelWriter/releases/latest" __domain__ = "novelwriter.io" __docurl__ = "https://novelwriter.readthedocs.io" __credits__ = [ diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index c64debe6..9bf4cae1 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") @@ -918,16 +918,22 @@ class GuiMainMenu(QMenuBar): # Document > Go to Website self.aWebsite = QAction("Open the novelWriter Website", self) - self.aWebsite.setStatusTip("View the main website") + self.aWebsite.setStatusTip("Open the main website at %s" % nw.__url__) self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__)) self.helpMenu.addAction(self.aWebsite) # Document > Report Issue - self.aIssue = QAction("Report an Issue", self) - self.aIssue.setStatusTip("Report a bug or issue on GitHub") + 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 > Latest Release + self.aIssue = QAction("Latest Release (GitHub)", self) + self.aIssue.setStatusTip("Open the Releases page on GitHub at %s" % nw.__releaseurl__) + self.aIssue.triggered.connect(lambda: self._openWebsite(nw.__releaseurl__)) + self.helpMenu.addAction(self.aIssue) + return # END Class GuiMainMenu From df29c71ecb58f619ca60365aaf94ec6275f58888 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 29 Nov 2020 12:27:46 +0100 Subject: [PATCH 08/52] Minor changes to the Help menu --- nw/gui/mainmenu.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 9bf4cae1..309e99bb 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -896,6 +896,12 @@ class GuiMainMenu(QMenuBar): self.aAboutQt.triggered.connect(lambda: self.theParent.showAboutQtDialog()) self.helpMenu.addAction(self.aAboutQt) + # Document > Main Website + self.aWebsite = QAction("Main Website", self) + self.aWebsite.setStatusTip("Open the main website at %s" % nw.__url__) + self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__)) + self.helpMenu.addAction(self.aWebsite) + # Help > Separator self.helpMenu.addSeparator() @@ -908,7 +914,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,12 +922,6 @@ 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("Open the main website at %s" % nw.__url__) - self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__)) - self.helpMenu.addAction(self.aWebsite) - # Document > Report Issue self.aIssue = QAction("Report an Issue (GitHub)", self) self.aIssue.setStatusTip("Report a bug or issue on GitHub at %s" % nw.__issuesurl__) From d3fa849dee19b28bcff65238f3b8f3877cd09ed0 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 29 Nov 2020 15:58:52 +0100 Subject: [PATCH 09/52] Add mime icon and setting to windows build --- nw/assets/icons/novelwriter.ico | Bin 108042 -> 18087 bytes nw/assets/icons/novelwriter.svg | 368 +++++++++++----------- nw/assets/icons/x-novelwriter-project.ico | Bin 0 -> 14637 bytes nw/assets/icons/x-novelwriter-project.svg | 207 ++++++++++++ setup/icons/novelwriter.ico | Bin 117759 -> 18087 bytes setup/icons/x-novelwriter-project.ico | Bin 0 -> 14637 bytes setup/win_setup.iss | 15 +- 7 files changed, 405 insertions(+), 185 deletions(-) create mode 100644 nw/assets/icons/x-novelwriter-project.ico create mode 100644 nw/assets/icons/x-novelwriter-project.svg create mode 100644 setup/icons/x-novelwriter-project.ico diff --git a/nw/assets/icons/novelwriter.ico b/nw/assets/icons/novelwriter.ico index 470ebe7276d232832485c5341a3595d4e01a1ec8..df88329643ac837eb41542af01a6e0d608abf044 100644 GIT binary patch literal 18087 zcmY(KcRbtQ_xO{DNMc2-P$M+<3PqKcP?&LBxvouifW3YwP)1~TC-}D zYL%i&QCh3^_~!G+?~h;dNOB*KJYM&{UiaK{?m5qME&u?ezW?_E0?>doZ2*9S+Kw|Z z)?tKm!>LV1JzY(+|NZ;F4-`WEu@5SD2LQBf^fc8i9?WcZc-6XDhw`0PHuYcm{z{~2 zQVIrxeMJ#@d_O#KspTzT6Z=NSL?YYLEzXsc^sZ&;yE58!79}A8mK-JUT`n z&WBWWDAx9I`&8Ebe_uSbsjun74s*^&d~8weF~m+LdS9^m{ za5=V+5f!@gMs4f+uh&(@pzEgtT@mKGUq+F&Pk;qahdrz6`o<{+_(N>*;lGr#;)95{ zzD_Tp3yV6cdrK@%UEl?s9a`2e*|ZvBq5}9`Zt33m3LlY-h^oeFBrW^Lp-kY^wBt6$ zjKkWLf4DJKmXM}valne-v*tUajE1**GR8m`%Fh4LjhZ};1U?QC=?$O0+3V92Vz1OZ zEcGn#L;z(|dhMs=?Rfi{+@gXtowtaJUvvg^uXJ%H=!~Dw!3NyDm{8z%|8zd+nm+}z z^s(hdyEyuU=<4#0O|A052C|8RcbaQQJsQCRLpa4kdwutG^ibzXDd$UF=k;E1`@ma; zI_FbuTmlzB3J1VMW9)A5m{&92w)y_GELLDheVj)+6D+Zl!)B0C#7q2vF(#GYr8JiF z{YW9$?6SQq3=l*r5P}){q##mNHTGEjoTd?rGHS;aY8QbS^@-4h8!2I1Ib7ZI{kpw6 z!#++GZNLOOJDS1GL}``*;dHq>*93FB6v;ZE3{gF;N^PX>FMPUE1bbE*!Ss6(*augw zKorm*+>CZj<%xET*rWM-_N3L#la$eCAxth4y$+NU1b!s&Dj)#_#7(ekMF-b+ zx*Z|iCjd&bWWlT|Z2lkiN6NM%cXZ-;u$X&+>azlK98Za4cFDPjw859CFz_JVKxYt* zu3}WBeosQuMOLCz+E3n;rr=em+WcV!Rwd-aOMcqG9a)ZLt%t!{YHzj@9?Y_KlQd#6 zj&tN%e7CyhV*m=;eVhNmw>FZ8+DY8J`Ld-suEdO@3Hadc+0Ml)KlZrv~VJLuFA50=&<|dPg ztyAWrBj5`Ncwezuq=y-k8Cl1UPRz}v<6bCRuGrO2xar1$aNv&M`jjGfqro_XfOgB& zRhBlt)9)hrVOU|fl>g`p;N&Sk1lOh>NOm`TifTgFT22|~ zLfQO5O++Gg2L))nB*pV>cHEngccv5{KE>TF*+>jkhW?g=dCv;i_<$PTBTh2rfPFV=huLE8^OJ9Pza^Q&z)@$stO0qjQnO zqU$ehaSy!U8*C-2pZ$@sJ1al5Ml90^J($#c##*3y*@?@*?T@4n!;yQs1d^Qp>2m+~ zvyg~Q^$2l?CxWLNE&t}MRQ#WC2+%lnA~h*@){YEn%q|U7nZ0i$Cc1qbDY3%4UTJH0 z#P2+Ua=+LU%S02`E)HE>4eP~8MoF)MZ{xk~1(5+*0RYzdj6sHR9*jP`0$6BM^-Wkh zWQ7c*<>e|M)H~b5xY=1IOBqpl;HPVLvltUtz3as5f~fkBYiWRxF>efz>8*FtT75cz z_WkMu)i^ih=YmmBBG=~IIkqo%NOh%oM%c{^r>fu4#5;57UV2g>8jvvgv|LkJ<(?rZ z@ViB7JQ!{`vz-fnqOaZRgB%blpo5vLB&&Tt+PcjD&`4VK5<+W{IZ5rLdbPYpm`l`z zo&`DrvdjycYMmjpxGdGU8InQ|Y2|J>X5eU-#NsPBIZByVW7zyh`hFP!wEyrkr!KW} z_ecuR7**lZKM9(+^5=bs3G@+Av z1d6^z-=E>WUr>TTD?Pw>ctr+owz!m`ex6X-oqD>?^}Ze6K(VVX_^FUJFKg$Ye; ze@J>$zQU~wm<{B3znn3siyN<0fQjPzH{DF~0pLb@z+|3ywOOb|IuR$4N_i)bm8IWV zcAY5artQ{T?%U13ek}S1=*p^?^UZT&eG^fshYPu$69u-BT#0^>pR}|As^tJ6(o>K~ zQYHh~PF+Yd^{~|3A!Y_p!{{zdoP{hT`wPDj^%vL0-fc=Jln2Vys}5mIt_VW_>MED| z1pq}xhGuf9E28RP(vI-FdkN#DGjiaX(+UPXCv0(gx4r?Sk)Fk}A)bNTl*Cq}9a*P_ z%51f~jXk|+X=BNP4LF}N+~<;dVfFRPrtcE+Z1(~TO5OEzhRbWS_<0>NaLeQ&HjNI) zXIea|Ddovf{BV=>*+gI}Riw`a>SL1dJ2DcxA1AM-D+(LUS(; zpFKmEP>%&sXUxJ7{kEn+-EtsrZ`dpi_v5!*I-zpB0RuE*#8;MxL)D`g+zr4~?FbuO ztBKJiTpF$E^PE@#O=3*);q<5i?#fuOd|wF#Ehi?pbiIyu1;Vgo#I5 ztbNi<<+|+j)~$(XCJZEo^<)bGzmi;WpdZ_kw?IqVtW39fR|tK%Y$CvaHbZbz;pbOi zu`q(`c}Z$2v50|HF!z-r*#(J9;~(@&eI5q?Xt2lqww(~xlXDp;7(4v)P6k1aKeEzw zDVM1{AH1gk@Y5JW)97vQG_JT4vlY?5lbp4)cIX3cze|w03EGK|Eg>twpnbjA|94Ra z7@h|h!$il&;!-vGCC4Tp>xwY+)5aNLVgdMP#(zd2VGMf6Llabh`VToK-p2GF)5 zU|!%P#)L@mA3$QmMcNqz+;uNj$A7xyfpyD5@CV_)K^Zu%Pw}E~G38`^k|^{tBZUH| z-k9-2J<}h-JPo+dlDvMn&k9+3WK_-$hQhcIS(OSFzK}-3aM;2;iM2fNP%dDs8MH0{ zQuh^_!EsTCSmV@vqp0@#Glr}O65rK-BssDaguyE%|L7aZ22YNHJih^kV!Gr65oNNU zAg`~&92+RU}g^>_&&ZhU$?I%Pl1^UyS*r{mJ zp=WXfR?eCF17i}rQ`%=W*_5YC{$LZYa*Y|8+W)Gr?)5XO5Gf@_NDb^X{6j3^!i}5E z{Zr3EF9Y}GOFD+^X(xshZ6L+<8_AbWrQ$m5vVbrY=4VRr_ft%Is<pqDJtTOpd~qqQU%xrC-C#chE@eW zsQcjSG(z%y%UJM`r)B)M$*>0tGI!ZaeX}P0G&|M|r@*sEf-?xpJJhR8n2Bg$lx7DxKD;7m~bW_ye7N zONRbOsQ|!6*+@{8Q*(#b*sga_MNbQ`@s~6^LkkHHGI3;+BCQpwpxd%PZ+s-E)g#)<;=dV~t;Q;5rh(MyQJR@UHC_m?)b<C9w4`Hp;$sj)@5eM3}J!u<=M?I zk%!=dnlPrl70arKjB|n6oaa#|JO5Gm!{F*3^iOIo1Q_^Fol@QDGR+7GCRmAzr(CGs z1zIkIVjO1gzg>Fz$j>jzegT zl(gmbaEPK?Ac$*THPE)(!pl7F9}h_oY^49QA{D%Yd9C)?#o{&46&AIIF@&`cpcXSY zVM1;yrKJ^1*gff@Ibc?$olrA;c~As(VC!~cP;?54?XT$Q2;qGu zqDEXbNOAg`q(I}bq{|J~{hKll2Q1W-s4`kbEiBZJ&VZ`%W=@9+Nu6at{?k-y|0dN@4k z{yHttvdM?=e*f14y<=Iq0JcgaR95Tj;U=?`b#WGD;zY1FNc05+Itw`6`A-v1W?an$ z7XbM^>ZBt$zx!Y4jQQjUZ}YjVf`KzmPy?MDZYCBa>2`L^o#7sW+1MiBlH(~GWd0%i zYUJK1vEVIp^AgGAwGJq;TlIpsW%LwS@T*gP_M zIGU^@2FWy@S-W$M`R`EZLJFVG!kd5x)4#{t4vbLWBV@|mZZ^7^xwB`a5ln=|(ZHv6 zG9o}-PGNPKG`U+wBVt->K_D!9gahAG`18LWKYT*{&{{Mkh?3*6e0#qdgQfr>4Z8^j z<~e!J-b;}61p`~v2{(s(EWh;;%v!G2{P@y;!CQU0aEp9V`lk0^J~^{b&8KsFZ>1uj zYadhK(Fygr>i0OcC22%d<1)jdmTAoGYtANp3s)d&@xo3l1l5;HwURt`9FT2q|AYPU z(0%&V>nF6}5$U@f`vQA>59o`kSkdo5gFHnhRn9TGI484L;5(b#q8gx}O$)ce&+4Fm zD!_Zbprf;-GPk_cR7A6 zwkQn(#?RLW4A+kpvn3+F(6xOz9r_D$JY!%oo=fVWiv;&J%+m}9X=`Hdr)yj%9(7fS z4kdzJ@3GHunM?^O3Ql6s6$aNH^PrG~YENvamAf(6P*x!53{vvpx2i0AM1t(BB8p;A zdDoJFeO$GONW#7on%2TWo4dAY+lK^sIyXheJ@2VY(SMwd;6Kmdi@LYzEZ)Vhc=rC*t>}3thO(3JFADHnel4ZB zzf0_+y{QPE$d!baG0&*r=CPw454_O*OQMAPm2oWw=siK3#21(HZ_?=3`PhDG@Y4je z&s8X$AEh`QW{CuxG5YIVJo*kA{->_DfuRQC^uTc(AjC3j24Z<-9)v6m`BlnN3%+9GL0)OK%wiP)Q_I2;R;@PMBm>fTAF zS8D9Gn(8*odtcRScxxVRQxH2qpNktN7WkBy=wI`S70u6sMlTz82BCB&?w$-@sC-@j z#zY$Oi{L#pjb`l?;5L;a?JxY4F()~4pzaJ767g>C7*=_c`>q5%z)+xv1 zN7IPMy&uvY3c=A|<-vCpmW@`NcM>>Y0oIP8Wx={QFOX+?L4?4yd(VqcS<6R2M*bT= zef|PIa;nneOVaOx)w@4t>ZmY`OV^IHc%u`YE;EeB3EIE$n{AZD0&dTrBkg1&S*;_s z?x4P(2-`HP1hSRx_~g@6v|b_KhribRyfKBhM8AOlQ~(((57>qtrv>aLcg;AS?){9O zuY)%qe=cXrl-*!>qV(x+Z%W+wjoIF{`7{4j)pJMem$t`P>Y|Mz7D_st#M!eVKI)l@ zilIDGQ)^%dy@Kzkksl0k^`qvY?76h5skqk+-9PaR`_`&-_~6Pnbq?X2NG2Dh8>ofD z0Hy61-@+4dQ(JhyCqV%HS=r{5iOf4+T(XC9G;(YA$;66jR5y%o;l`%qn~14bH(+jCy1Se2PuS@+%d-=1$k|QJ?N0?wO0)uK;x!dox0uyq8t?lc?*||YP4pF5 zur59_1c_1kH$7%EcWK}EuF35v<{nfsGvo^&lRChr8{lyy z?>WE})-y&+9T18R5xM1*_%~vqOKo(64l78!9Nc$a{(|?hQWl!15_$2ayD*YF>bq(Y zHuGCI+)MsGT)I{b2VrC+1gfo=QVG{@jNEKKF`{%Por)ey=6b4;qJJtw6Bx0xb->d?GepNokBT{ln7o3L)AH2sPiVh zO;l7>wA#Yt`@fWkKf|lM8%d`?9X}rvLi#Vjo*u59o+20dzHXnkGVT3fH-3)+>Y{X# zH2NZB^T*HBG1ITyncb^v9I`NMOsa?(N80W`r%nZUk2`}(o!A01Q{C>~-^GBxke)@n zz#E8%AKapaF?S5Yrco#e8tG@8=Bd>-4JP4(x)wG$I~n(P-i)iY-zzZ$vM{^>UQw4r zB7TmPeoRweskxi8$X&qDdEMCm^`Ca{Eh^Wh_2H;tEaN&g<(?JLe zWLpJlJyT5qDlz`3%HBudWW$?G~cOt^WlT21jO&(A*f5}h_lC4Mt3bZ(VYjLy1 zKea4miJ#NHMCZVI5QgqupO&qFM8?o?ui9FR7(--4kJUtivTc{D>eZ%0OVd_9`GktNaxuWVE&ID8RyrV0s#6T|MFOhYqUQcE#cq;j6n(z| zn|N)q88xI38dwaNrY``Y)xx0_+XL_&yZL)Y%h$duk#$;{!oNpx-gF+VAA{edZub*8 zRg!Yl+4-Fu1*Q1OM9|FN_)m?yO1j*Sa~kA2W->RW7oP`m7F7ULL;TAb6Qlb5SXtHO z;*N{+fT$o?E*KXW}fCuNS9|BUxbHC_Mi_;!B49scHDz4z^Hvgy5T z$&;WLuFkXdq8e4L}D{t+#nf$AjF4EI3RYHQbL-*j{FfI{?a2TFp+V|2UYK|rfXg}D-$;mQ@ zaE+I?@40y829w2)bk=V@?}G@MIV;Ypj~u5~ba#sx`?pIg&JN!*3>a^`oFV-5zZ~*M{0-T4>>mWlzwqVp4w=6dI^y2vlVV0q!*c%>{L}@@;_i*CoJ^g}kK& z?b8SiZD?WJ=H2$-ox4g&9IczucGN6e+oi31zc0yj`kR?!A}`$fksuAIq`s4_B2RzR zud9o1T37EWZvXHLaa!_u{(f^blAXon!Bij?;lX`NL{IG3ih1h0D0@Py->P9!o3Adqx`jwCNipvCuF3p;(}==Rp$ zo%w{@`TgUb_s70of4slK#J$O=RDIRdNkpeM6#J~*MGh@pzD&7)=Rpz&lU*cy{#qc! z<6=v;C5rFv7ex&X;e$OH5zkVKR$P5FM|bn0}2&;+~>43OC0`FkRDI_~qGP~H%8KZNz!X2z=T2F0h@>D!-y zS2?H}!R%M!TtK2<8+r2aveK&BmU~#pZ_J;=4^PHtyW(yPgLvpfempwn&EV&oqh~VEx-T(U`c8=J#WSGkXHV8}B>g-Eb239UgWAk}5cVgwR98bo7>)=?BexuIrY(Hh80#7Xk`;XK-#gz>sJ? zBW@4?+n7r+j)i5QDI3 zrm6X=@q5zN{^2i2Tul=YKsA+Ln*LRO2rc3=Qp5w_6Q;?s-k+OoR*SkxWz&5~%78d_9AmzWZKYdkIOP==?>+1f4ZzlOE zRm-qMFnI^}dsx@V!p0u)znl7db^%CCmT+_oh@>2T#9<$Q{+DnW6x$a8xFJQxKGh)8 z;v{a0Nl^^1E)BFNPa4GGq8PF*lIfjLYLhg8wj@Ods?r{gFtvUY(eC-X9P`}pe6iU0 z3gI^2OR5DtRy!04Y`KR71oYOIig0ow9OOHg$!(9=d!3lO17c$XjjwvAG`+$FRwk)4cVj!{wY$m?&A${M%DSae@L$`ieq z54sJc(Gb4%@7cU+Om7me{LG!;t04<-W@;hd;IOMYI9%)v^0V30D#~} zk5Ehxp^sg3$c@xA$Q7q*pW7zjf6{>AaJJH?5pF}l6LJ}8+Q{|~KT_h>nKib4ylPuG zHfp@8EpqDn%@Re1qui==ACZe`ZqoUmvt~5Q)XU(*`iZ=dfV*>=*p18aMejlhc};|C zzVz!y0MtP-c4V~z2N2REZ=T*(*NiqaKDje{ov7}^nzRqen=`7O^M_wi!>QJLMreqw z4qo}vwF?ALr1V*&%Q+T=6wmWdrdgQiVvBGQa1)8wt-b1{n-zcLPkZ zA98^eFKKlh7}77~kCbTyb+x^Q_mQl<4w7tC?;wAU_i#j!x|93%=c{HGX}qNOi80pH zKzu&}kgPZX!g(Kyh;oW=cWcMha7sMM$>3pO(@i@Z?UN|i@N6yPWu1saomZ`8opNk- zG}$JlO;k7 zOaDYyT+M8rnesN0fB@I=6M#zgV@uuleIfFXI|ipb@up7FcY>vGC!)1xmqc;kPh{^6 zf?E@S$t5e7CBEYilYQ5gF|6c<{kCdR(TDPXm%Xi>CRLHV|^15NLy z`72c!?mijyI$JPDau^`7ZahDRp6Roo4=^SfRJMF;lOH?xJLed#d6bjO2H8f||GlbX z?f;ff^4sT-^C`&gv86i>#n0$4Nu!>@`5X)J8M|}?ASR@G>5N@--nkS{PS~9=J{GTSRu*T#}L9ho4 zGg!znocnRC4VXgPrrpa*(W?Cxsl-hHAq_-0>{ z9AH5@EK2pA=cFABEW@MjBo$s~Dj4oQDzVHkt#37^^X8{@&(kFDF}sqQw5(4GC_y)_ z=2}SKRF1eky851X`L^m(r;nlX%F%463_P715I1%4QyRx5Mdt35cul+YB9cw%{B=c7 zK9Y#ZQhHvR5@1V_q>lzy}U4wVv)<%|&SDAk^F*^D2a<+f;)<>m%B z>ff{8kkcgJ_?t7ZRt6GSskkV*_cqb`(F;nc^eX9wRY@h@pccb$AE-2g^Ab!wfnmg& z*6o!B$JF=VOl?LPx4QA9ggT$zb`?IhFCc39Gm0N$5oPlTm>-Ma*k|}qdN;Sv2e5() zjlpL@(r43!b{Y#Dcua&8CYC1xxTY>)0L|;?gL^o*NSp9-zdnF0$~PJTgfXNK(?hYi z=M44T60-j!?t?1d7HryeAN`|x z7NYv?oY4G$H5{f}=zG73(RvVsc&v@)J(g%rsy!fVRddEO6}iJS^q zL^}N_R+>?GQ9WI6X_EK-5m0{e0jnTEj0IDa4Cg92w$VKFnK8R=H_+jsQ}8;PA7AKB z*#Aa+YI3PO1DY(%RRW$jIdtoOIZ-`>=2X8U5jv>V_2DU?_#2n-{mAO-Q>*8HJ})Yr zlz@BUGVfvQ=uP&Bv0Cb0-A|s*$w&`k0ZSiOo%t?Um2WYZY(nQIew^XX>Aa`3=dj}ZCo|Lkt z0@3h$YR_*LWIN&ncG1$(3U3Vguy&&5v|X8qr|?KI;vHJA8jglfJO6yrSphdS(n~dN z-(V@ZoX*-YHl-gI*9rer+qlqwB++rWx*fbz@+X3nxMe#rguK)eAw3j?cyN znBDzoJ)eUspFvC3QqWwP-IdYL^CRwAUx-KlXZ-C<1I16vaY%bbOq-|`M~M7jW49(M ztOw!#SH%+#j}RQPIHZ>kWaM(+(6smqN~F)GwKfE2%xdhSZB@H)cb>n~#|=AKzu2NT zxB-v4;gf67G$0jix7@_wchovOM@2c5^cNlWZ!hb76HV&^I;6eFGmRxK3`#)NE_3)dbXA0^YOL1 z!GqtKRR54M8I%HAzf3aKG_68gB9|uLf)Oot!4E5+HI4=7#zb};%Lq;+<76kf2{_LR z5mK-wR~6cJ!rQ15)FJlBxGQ$6<}-Pe?@A*u8cfjyvLm`NcDoC=xT^4>rq0X_;-imf zd%Du?vAf30Trb3vP5yuqCCq8_5bDx)tv%Ld?2>e^uQ{1N462NY3^aZ5n7j-w;9}AJ zwA%#2XAR zb=rr<6b2YTd4?idB+6o#?)jAN(*O^b(f2t*Tu=wxqrvu`cWQ~^_QFqp z^~O!|(;4(QcSCd^)ft^ntTq7m#5!wIjEW{PpBp~@?Zb9+^7DHFe`T z$;N^xEjC$ZGd-&*pbfasBY5w*0QN(Z&6S8Gw!}X)@&hP4ouL_wn_ zQegdi-iNgEmulv_b{L%Hvkg?XB&K0vdotiO&0GW#Yg6~$T3f<~1H+FO*KwHQ5s|jf zYb==JeCLrl_c4OC`ujd_!eylwtwn z;AyRL(Eg@wK;`oJ5TeIja%mG-}I0EJo=MCVk_Q~5o>Q?A(# z#frm!MJe+^Uy>)q_p@1r4?pQTv`BkR?$BTC9N?H@AZXiCSr5UnSXVp%2vK+^l#Kn7@C@0ewN91ilRR2IiiM;dAXBVlfW~4;)>2C7uP2Yis>o zCR-wa!w~k%AeSlvg5lOY4S-5iO=|0zdJsAL2i;2E5${man}1!%FNAW@0Bj;`!S2ze z>`h9&UuqFIz(Z;1uc#~BQhRz4Zxd!QQ^l2>W>16vI^O|i2|(&rg$#aB5Q^&ldLr`N zU$2o6wTO`U^XG?M%@b;iu2-P?;Nb(W5KcOU@{5z^8(u3@e-tJAqc)GxncOnxS+Lf9 zTv-MRV}D2TMTW_VDxtB=VfEPhFSl;PmBL$&cI1yt@3;0kpvt#v52{^8o9hH@2XFQT z6i-)pK3MK;^+;+KWxm7Tw&~~lK$T~d-wUuyS^+D}%-pWk^G|g1?*gyiA#4KztdG1E zNM5N&6Ee3eMVo3PFYt&E&vO@P&wjV>reM?7((AQ`h4d_GWbErl?4Zyx`^KBsv52&8GyevUh{K&8NKb+^& z=+w4UMd6l^S-mcqy10thV;2wruEKp;1hIiA`Xfw@tA}&&j=f6S#_6>e(&G8Vlli^KJ&bodh^d6nCqjaQvi9IDnM3$NJ75}(*+zhph-Hu;CLuq{*!ro)_>sw zQkTKTytZb@=DxS*m(7I3{l?&reU>%Rx{lfsh;*PptrTa})lQn4?+5*WM5|@`LhFia z)H(~9wm(0|MnjUHzmH)Jq0;p6ugb~V9<0qv!qo+@c4{#Chs?>~;He8*UPO}ZXij5m z94=k|Yr9uKgfdcp@gd_z*FTZKqwh}uiG0Y`h1QldrCf7bDmM6|)RJm*)b-z~WT9HP z40qN4K*rd2CCIc7;X^mS0M9UzgJ{*mPSCsB_{nMmGb#3QL7N;W`wZ?Jh!`J)6VhD8 z3gjB3MBdg6DIZ>Csnf&0Z<-|vruvSeFQF~7fV@v@VDB{2+WTPJ?VPbPouQ@hy3+VQQJMxvnp1q#PPQK!QcG5 zV@gbRIofX}%n7+8>=^8Hh`T*f4sR2X-Hq}wR+O|X4Obkr+^yF?WW4Lw$<|y|hG*nv zgVI^gr`lcsjecN^nf*N;CDUkawr+Q7-B#@ny2lzCh+kGfA`cZzh1nN`M z_WKu2eaD6TR+_ogzo78VYta$MR_G}qy7w?#aQ|?+mK!KI`c^C4PC^rs&5DFu@ zecj6EH5u+(%%6`dbKHga-Z)@0Zr{1ieMW;#WMX1u@ZF=C?jOGWX7S~c2cRa-y*S3* zgMO2Wd6e_1VYX;fP#McFE!x?)2kXPJoqI|kMD%9Tke#l7dwlu4&H$?V3dF8gNWu-F z#qzj=Pf}yS;Z)k@0x^E{iyGG=G}wd0tuNNj8NZlDuXd|3-QQrcnFRF-|0p>2xi1kg zJ7E_Gp#2b~q<>pNcbV4tDyE0GYy@2ScI0e;m|*<2jArMaFvS zO3ivV1~xUtR6OI|xZjLd;R}ll?x`97s#)AYMeroQHjO9qMX^0w2`P&UArVn0p26%& z1tD|_;}4d3wuau0=UP~}^8-3}xT5_-IX9j}_{#llH0d36G6ux;u?c{TD{G5qR)8Wy znGDbu0lv&u--t^J3si*e@4M9Z^(1gDfWJ3_c@NKd{{kV80suhXPM&QjB|hkjTHi~} zOHRE$)D^j)Fhd1!RQuArL^Oksy|+990-R!_L{#nbw^Gz=TR5*xcP0&@w%*_RvHp?w zOY0J!{Ox?Sl@%NP%mqXRKv;Q6el71T2zcP2L-X)mFztc4NtOpgXTWDqc(oEMi z-iJk@SNWZlR-KeqFEc!*W|1W0@loAC^?Wmv;m2-mwRf14fA?;4*Ya{^E3_`^43ED7 zAX)w~Q;z8X1Yttbd(Y4M1Yiqn0YG492-uyV-)jA~)vmaao-R;6fqF@)6B2i5?ExakT4+RxK2WMuR(MOw6rTfC_`d!G$ zN$GI%TG+Vg^JBj;^AN$-Ll6`20f2km*T<9mjP<{csD z3``flYe=*=XJdOmV@#Ny0Pf=VGPEXpmfO0R9f}!;?>EU1UM>AnREB>*JZBv9irZ== zAFh5^>ewg79?C=p7zJh~u~vm2`WeDANl5zWu|v4O;7EZg-g@!q^tC#4<5Nh~=~@@a zU4^=*xrFkBB*gNS_Kb?Yk2(3yuu-7h&_LTY#VaLkS$^?&U(IAoG+<5>z$!SvW~so1 zN+@w!xlTJU@IpFWH|LgEOyuZqmh*R`g!g>w3NW^t%*Az==r4{=A^}k@W#g;9FFNjl z92!(wArU0;sYnHVURr5aZnVMuowIX|74-t0G3SWzbkAa5THB|3o^0>n?<_$2PLCKH z|2$&dw*~*sxS++LemvyqP^8U~Lt;IJ^rrB*BOa(gf~WykzTV2#;YkTIhV@ zY=k!g$%)29?kd@*h@384UkE-;x&`_e4W$M}hUeBST!zDAY;G~!CU3K`F4Q9*%vta* zxl;QX*>_;FXcd$NaNNe*x1ugM^hI-E-}=y=NK_d!F&LA)ch@F^z@yyrH`EU?_pRKc zWcgJJh-Sj}l`tt7d<6q)3Z;1AxvB`}@{ZYz?BPZVxPNV<8EEQZEN5ixGzNeUAJ7p! zjkq1KQ1)|u^xpYe!;@4%Ut3Rx&pLsmNs%FASps%H#4`N&;Jt}!$Od^++Ed_ZiNXPs zdgOvFdTcMhtv@FKCOBrh8jk=@x_RtwjnaAMLgQA=PL4H&_k|=cw5(`Sv2+e6h0YdZyL&`p$;&2$_twqg9u+UipAwQ*y{;Yp;_*5alNA*c;6`) zf*)tsu*!0Jn&~P}ysQmsf>LKDZ%@zj59mW76wt6V{?Lv{?zT)-Z_d*c2wK8F{zHkk z9IkZPDo@4v^*GKf`*&t}KHw)rLjmFZK+WaS%kjB%dR6#p*VHO;)0c&O=#ym*kD7(@ zG+gK+CwW1yv%aAEo|q^=fd%@BdMu^1%2=X2ih*tJlean3I}2v)(boz=`oL$IW-Up= z0rUA*dEnP9=WvUQ^ui0wBqs?cJlh}yB9pBUoFe<2u5>1yYSwZ0(XW+1Mem$bzP4nF zH7&^7d44|-=0O{Q7>d8eWOFsaBkz=j%TKI3LP-zfaGN5!IvhdV$-BbwlA4e>OqMrY zk+7@!Uiung`g@U^@d!~KHXFN?lLfBN3AJyz7|EiKyf)Q!D2b(FZxL#|2|ELylFU|! z+PrU(5}2sLer2foB!)}>Mv#3aZw6hgLmM|HmHmA8po^#Ehf2Yby%2tAdc>8 zG)K%a38@C9%c#goO2Ag(YEQGaa#VRmA4dcF4kpspG%FLJ@x3Q!EJE$`)*;30OUWnLU%z zhv@%$zK?iG_dOONuOcyAm?%ERJfdkTzy~EUH3+oEJ zFQl&oq#olkkPt1syNf-mZE;wayaX-DHtY}l~iDPi4ep@0$V zBA8lQ;~-I<#~>`WKGA2%S{+0x?>vgO3us?)O&ANW{H_!Cwx{bd>p3^AT87u>9%i;q zW-^pY8Vp02g!SCX>?N;X5vUitYe7jXVj@skxK(nr!#g%sBeFXZ%Nh~hJ-t!tbbcJe zkG)1`l1F76r7Co@KZZPM(;r5^tTW6;GQ&r*5ShGF@M_w78HCO)^NzwC+kaQMeC{2J z?TA*>@~idqh5|agsdvh$>2qx-_J!gL+2p<%qLRQ8<_^>>mu6iMcw^E3eNPQ8hBPXp z<9L|mH|ANgB26{6-I%!SP;C6r()tt<5GtmX}R7x#p>JnrlJNB#SijyximexH(3eH^JfF;sF|j%1)S@D=ie1BA7d_{etfcx~H_bM1 zv<}CbzJ(@5PXcPDtxNTXbka-26S&Quz zkaER|+QD3l9stxO1F^gAOaFm!xGs9EW~`z7Y7bRes`gkD1>?PGU%A@x76_L+Usbm< zNJcacXBnlr-4IcVk%mE24*GiLzg`DU`xDJ7?sxyn@ew|p@yn>5VqobR7m5Gxt2_@V zwvabY_GUDI?S7Re8dj|(vA|LB;p!#10gfw7qGk0JrDH{306X_n_|V>fn()g^EZw1} zUBnI}*I+a!@ zZc|O*Ashk|5=a`h6XD-Fg=TvT-K~-EJAMnqs#y5vDfp6vEb(v1hLiBXjy|7F%l!r4 z{fi%b5x^M>4t&}brk4KvL=;4{(iSOy96^lpXe86hOZ2kUwCK*Vu6ZJgLJWEb*ni;} zSfBqm+-nyB00-?YT)z7aVBH-wI-^H=+ndMXdQJ5{zb>=|Rzr+y7JdLY2yP;Xh5+#0 zN8kV6yR+$SK`9>w!7qduf*}F{O;=h#BS<#RbmxFuM;Wa^*l0;It-NwmVG>%ghQO;m z8(5YU7Xoo#D6!yrO^~w>;MV2|yn5vVdIx(57=vZo@S9z9woahc-GSpbM>c;&bt{5U zyWmR_XN5mPI2L{|hL7;_a0!4>{5oOy1ppYbL%5^~nJd3CB9IiOG!F!kED>R9A_$Au z5@3F!SrUS*HQ5O5DrtnKi7WMvzOd&e76e3fm*Q!SmDXfjD@@gb!Cgt!GGf@mPEq}#9 z$n;MI$+*dfQ*)ptg&42;1%y9{`WQz2@z?+U===ZbXGB#&>5zPtd$J15d{f>7b7KwqfQKp@lX$y!9XemwknlhCj1c#aTrWC zMz7kG{!LjmQUdtsKmO}46VSf|fH5%i*d7?7Nh_2@kO{7h2$qBlswD(XQV>P1B0*^M z%}n48e`HBX#(fY(Nn6fB><2f;sy_qRU;H2c@aN9Us#Cg1pZVYVTdat@T1qLuGx+#*hlisJczXGZ`1X06qs(veHjpfCX8hsFLN0 z6Gc&o{qQCji^f6kd-VN3{~uM=YVDCc``J(Y-s|4>t_~r1>zD*6EKrikUXTbRsiX;% zBDrK_P*<5nGB1L>>%d&tN~k=nRCI|l<%xo@kArYG;q=2ldG!77{d+pqYj2W1``J%? zGS&p%0f2-UAR-YgIQfmW856-YxX7O2;*vp`@u;|#nUzwlMN&a&bqKw7xlEBJLT`8r z{pcW>?R~n!4;1*#-t&te`bmc9KLKQWQ~-fZJlOElWAL?A7u2CcxLQFRJ! z(Qzq{MbrsuaA0Wq`sylQN8M*ELO(pjFzO{&{i^``i;uql=l_>Ua|->c@A=!mb_cVp ze-7Z!B*Q5j*u(*a4VpY+Ahi4Es1+2CG8j1x?LfP%3bHSv49^<|e!e>EkqJHnL&QRa zY=|HlOpf;^@ZW7p|E9{}$3v+p@zam|%eNCkeime<+!p{mbY|KLSdyTn6 zdVXaj`iQ2Ey=;=lXvF@(M}PI_|6kGiE18cEzwbkD2qX5hgy3NSchx##YpijEL4b1% z_y{HBH$L)@{_0mND6d*P{K$u16M_8@A>?%tqSsJHIuWVuHE(OIv9h2HhY?|W6!duz zJTs)>lmGKq{@S^Dw`+|x)>vbWHP%>TjWyO-V~sV|SU&v!z#7{~Jofz400000NkvXX Hu0mjfpYypnbdWxR1wnqEqC!|u8~j?d2ofAbg`8B_ zvYwfVlL^h5dE=+LrWw`K#))C)h8+{&d^nUp|!V; zCx>s)yK_Y4NzCLtXT5jck@@du>(8*Th`W{ftfg2ee1q(av@ahlUsThu>^B#ha45Q= z<>NAw5`P*3iwpxF5&ah=!n-)>fvUXZyQq0jnPzBkA)=dPHVcGJHeEk4N|eZQZi6o96rZko8d$K2IFFu>m$>gkBf9``PUtK}20s(Y`_{HEOIOYeTPe$6(HuWz zZ`0>k(zD9etb(fdIfZws0w&2Undh2wIYBZ2Iq#qy9(-fxB38F>=Iq-Da(Q}?_Gv}d zN3qXUmTIZnV6EeB*$8Q8M4>B#7CGWm89vM z6!Nc#>wnYWwT$eeq;Me z&pRa+dx?nP4B~moQk^)xclR5s3JN63tmS^#3$LtuCO$iLOSWkBYqO+ei(jp`8rC`+qM|hNj&RMMRnqb+SZ_}(hh<~6)S^i1%_VCS11$9~(Z1DQ zH)H;GxyA1%FIcXGK(#Ezic}ol=8w=>itHhU8 z=j#>&8mY?)!ltGxs%DqOEZA+z>cSKB{UOThOi$0vbF$}zg{nAx*;Ad|QW^KrV(!t9 zf*|p{pyCAEE$m@yb8o*%FDa?>-T!vgQC2=vb=2XuV8J0XRRayz=gMYne&UvG%F!DJK_|e8XtnIo=sWO~>epSdCo{EW!88RO>#<`AOyjz%kLLlCm>c{UjFk$ z)i@cg@{;F%x$7AgPE}tx@1gaiB+dH>3*vV^SoCYc#q=*p3`%p9vQ_d$vYdloqZ+%V z-q(ogdc~8udl>ncH*aNqwNr4a>0B0>IAKng*#hc}0vg%T)z;TEzWp$wO66DG$heh} z@7OF(Ir@Am;>${x^aBF2k*gTCnyIQSJJQmG@E)Yncos_BkS}s2a0P)S!8&38PA`^? z0%CWng*g(fRgeubz6^ZJgI>=j7B;jpD2XWET{t0(PUDKjw;w0xP|fsG3@?n7B1Tb% zc}T&=B`yl=yhOuOrx_8GZ?Q&H(%Qe)dp&DnwwowJ#s9|m zM(nk`$)?3DGWYHw()s5J6Xpooc z3*|l1?ryM7nq-=%Z*Mt0=JZB4%jpe*n>=M$Ufg6ukQ`BK1PSMtk)QQp!;B)<^qL?M zJ_3~?4K;GiWBP^Mqu+KG7si`3#NWHeSztETD&>5%Wo>x4=X?9Hj(KmcR_N`#ao}LI z;|^*hD=EqI^_PrSpK~tPeoK{pCObz?{LDh`j4bPntniysYqPDySZ}zkxxZ)<<9tQ6 zfXhBoQW6G5_f4QBNvBzJ!eRrGw)I{qlPm_?c5>hD=$ z4t0*>W4LO~^@|*sr&L)L9v^#5boqSwoyOVPF^vyTCg_xA=FAq}du3B&L(X?D+Sipe zs>-#h;+nJ1+$dU@cRswiHa*?*;eGoD%o*Oy;#XCc>CRzo;%H_fQYrAR^sxC}p(dQt zRF#zFWU` z&byK)r-`3iwl{reAG1{5-tBZ~%g@8v*48nl6Q=9tZqC-;9e?wZUVg&G=F7?x_H%e$ zdr6;qLs?DLxcs^J3VRkdUbW2=GpN(*!tV;tqy6emlTb{!6t&N1j$t~lxG0-ceKC(g zdDHt`_h|FNj6?6}F;6?s$i(Ng5w~@@3|F@2@w`i`iDb&p4|4(JC@LMoheddF?jV_h@rs`1xGQ zw>ySSHO7QiDZ}j8RdJP5-=&mW1pbtKn=sycKcUe7NXqx8??1d$_#Ay8JJRZ8_X{vmDJ$K}7Oi0$LtrsnG_|f3 zg}-#dw1g1-SHU`!d*UwYe?Qz{uq0Ueu7Y(gQI$gt!fds=p(Ad{BC8^q;v%)y;Dz7IFPDXJiC;{r`_&>`T(s)C%v9vjUTd=_W@!Oi zX#CRTmC_o6=3Hx2ZB1EjT$XnFI&X>P3Y*QRx2rL$jKT~Jo3~PqkFwKNGjk{vZf9`%Y^-ob_PBnY zl5%T^=Y77da`VP`szi&P+wbPO#^Y}5cL&n1q6y+Z0wgamBwX0f8ih1{&Q007cV*6@ z_dlNT)XAUt)WVYSMY`77Y3GjQn#(oYNSdfXS8NeraJJ@&H`e%goTp8z&cOD^Eg@k) z=KQas3_%uW@=bRqK?^|jeRYjQo`3r}4 zwbeXWKgGJ%rEOK*Enj<0TElN{;@)h^O+Mc2rY0}>*F0_2;=dqtbus8ln{8WeVnJJ( zd~TC>+6jI27^bxd19Ios=f^UH`!js=c7C?gwLE@gh#KuvMPFP zg5KFH9=eUb4@B<+oQn`q)5&A=RVq|82KhLheb_5iY&q;Khf@Mc&Q@JL#=qC(BHb| zlM9#OEDp%WgT5#>llh?0GHQ+7UGi#v~+;deLj$z$@vjUCY7O+`Bl(sbzE zlYYF9qPqM1E%SaPFlsfCuyf9~1m~JIwbla?9}nmrfC3k0ZrbE<)zkBH#yP@j(NbtDwFxbR9*rpKy7k50m9;-Bre!s5e5uF8Nrx~o&-hZRLv#Ac*D_Lb zKx|+ho9H;gDNiCHkZq}Y;gsa1>b(48Hb#+XUK!?REh<`Ukdz~|?Aos_1+*6@T|ID` zrG|aX%C%3XSOmQ^aA2bHGTG-3jsEt$TWm}o{4dtd*mp_MGaF5Gq%>CV+zI_13L;39 z>nvIoIU42Mdxy5AaPL)MLi;%-cK`b?&<>+2AuwKFiFmvuVILPAgKW{a<1`snZ(}PJ zcRJqZK$K1_rizxOnbh>0=^2olYIY8%^QpE+95V;zc-%Y}yo*?2bT`^@?5;P6Sgz&7QE z-UEl@fqCq-;|NT1`R!F^QD2PO+h{U#`U|n6NA~KKeY^YS!>ff?zW(%@_a;v8jY+Gy zDyg>EEeWk=%Y9rKi3GIaEnklIHWM@EO_NvlJ%G-J{cTz*HBf+qH^`(e8F$^Rtxn!b z=3w@Pqdx@m?l#M0_=?$OPkl2l{K=GPHCw%+pAEUQO6MMPpN>X&8I*>OLY~tLoV=uc zN>SF5Z3J@Q-(CHmo1yUl_>nu*i-jBS*2 zzqntsuH3A*XU~yv()9hCW25(ozfI83--nLBzTU{)n;6pg_Qn!MX=lf2H*{k^jURhk z1|fKTMn$eyapc=lc2mC+PC99OBhzVu*@72tx`>O<&9!c;SkIN0x4z_4b*_-g`TV9S zwzajXPtHt{{26Dv=WJE=HFnok%cs*XJ~v+?@fxd0=BgrYtMtOBk9dSt&?d&*S7gVo z)jrxx6`v2Sy}5akvFC>5CV{h+jrm7gk6t`hUZ}!O(sG->W0q}c*`*0WDUBZ|Nk@7S zrG+@{Rf_H;D6*ZSU*Y5WE<0aB`0I~nD95tW3{7- zV|l^*=T%xB&pfn5vi9MM-LnqRAWI#n-ffum^{8&v_S`Z2&(u`V5s1ypN*^5#gq?TF zNy6nXJjS|jax{{^U#RBREi30_N>E+9039CXx8zq(cY2e1i3(9sb<`E*G`%&)e#5SW z?RQ8`waxqYOm(7CC@~Ia+!EBJE+_ZIl2m?ee43x%)xbQl9anB)3&Q(44U~_Li>Zj3WK! zA8`}>Vq&HeT8pHOJ}YLvzR!RlfpLi|wtGx{gz#_kzmX-wzJN1EyzPrxvT|mc+QB)j zRV4=26-l4`rG;3jY`8X^E@hoay_1iYF4Lbe$8GgO|mwn|?dI>pqogo#etXQJ1MkkSC%X$FU7qmO+J zy0t2y-sfh}Z>+tW)tH-kF%6Nv$G@PSZ%uUGjbg3Y*EY{| zeIn+u^1A@bjN_-W{MRj;frN!Ud*U)>^(|fonm|{>bXnzwnCRVR0#58Ui?7Cq%v_cd zQp_a%>vbT1G2^{Y`=!HEq_+CxF5NPXPI{_9bUlsC=ckoL|d2`eY&z@enHXGaVQ{@PM>Ohz7h%4PIJC|%ScI0p?tD%rf5uDBZ4gT-4T0; zcAeVurKUwPa;W-@j)7So|aao?z4diZb8$xb&-V?dq6r+gL548DdCy`| zwB5OyPU0sne3$jgh#g5)_PQxCF3^4};q2xENML-?{D6XUVPUuGcB6zWcs^g*`{$B` z7hj0%>yS5(8MaUd#}~0XA6J;?yJEQmTBfcxs@1_>&abS6#6v}>4Nc;|?@d^`S_FA2 zU3-j77-0 zqGYv(hOG_L1TAyvrE|6*lOq#;Ss=9e=NVnyXN^NdE7nb+n(sg3vgY=!lGWv!s zp9E=-GNe;*Do)91J;n2il0=6O&RHbBSzk+B#iQhMW7vb1DKt<8ayqEB?xh1kMVpJU z)*4u@XUKOzM_M7#nG0-$ImRX|D|XdCvuj)N7IkF?xm`K*(xx9*I%@k)KvK1v&6L^R zKQzsEd7ZmrRUEzjre>vwf=*(hp$KBNmN7w~m@SV@M{5Ip{$w`EAO?cPChu|7h+(?4 z*Q+&wp={4lS0Xg` z^=Gu7T*QPZ=Tafk^0SQPR8=4RlaxR$o%ge03OZv9CWx1x5$C@cJ`Hkx^WvJdtGuip1Nop zf{0XC^F>Pcxc;Ln|Rqk2*_PT;}(hMxJX%3I3 zuefj6CdH%>fzkr4h^PBCn;0%$+v$>%JkISXem-ZFK(u#r-@V=sh~ zutUGVW`C_jqd(Q-DVp=92jo1U3b;|>Z2KhL?UJ%h!{w#5j?`10q7m;@yjm~MwmNVr z>-DjvUk;7q+sBoD=aX8TN$Hco77rFgx_NBRV_qyis-X)6=$Mg|7iRSHbW!79r22;v-V zxk+4qeRg$SgM1(B*u*x6 zv0=@OxSHrhp(87s3N6GquRcy}MTd0t^LeaaYh6-YynNy5Y22=K`EgGYn(on$rF#)@ z?ZOjlPBF2}6J`~3GwMptrX}%+An6JrTWz1u^zh%+fDRjeCMH%KOHAatc40;CDK2i; z7Wc(PYiVVFX-+S5(8!36F+DO~)Xa=o`TMtWAM2LMRzCws@jDFnKFL2HdpW@Brq+@l zd2LVUAIMof!P@GPb)za{f(Hj8E=?5}c3J1nY}>OR1rWlA!a)5nMc;E70!XuElaH8~ zzF7W~^~PJ=lYSk`)tw?$%*|1{>S&`wrSDTGgDBe4`lgC&M}-O!8V5kg4WZV^nuzn5=#j_kLO7@w zr{;)0URPHauzhQjsv7C%+*NUm2n`|^7?dPo)>!P3qI}nHkwa>|>w;g2q|fr>#c#*G z_-K^QhE7EoW1{y;)Tc?cWk2Ac&R)E=HVz$!a+oGMiy$wV_cLskkDnriZdoOa8FxHy z8)Mo80u^CSkXb`{kg&wBr4FgHlw@mZ&}=pyE-Sy!a-JwV&jvwT{V^QW;>z0XvBMcw z?>lgICy@Ph$vy^^Yl<#BC~f(3#vQ*aE7TsFdH;mtvcm07x&6$?5A(M>Y3@&^&QWY< z*MT^jHY&1-3Z-dW)DM=3*iZDSi1cDzw8S#{Xxc!|_AH;DFyd1%FO&_7dei>=R_Jb- zK;Pewf@LQhKm5ChjCo?T_Z8an9lw1y12L>UU+zdR7KUx@ye!*Z#mDly2$L%+t9f19 z`Jh%{INtblHtqf0kd&Wy0on?FVWHmN&H`It;A-A)zvE5b&akhx6rFdb%CS*r3OXQ@ zNu}sib{7X1b^I>dGl|vQ}1k@gmyd{hr(AQE~Ipb?HkFG z`YB}@I@OE~=R%*yEfGR@OPWb%7k!Q8Glj&Y_yY^3=vdxHm%tfrF!FI5ni8Hq+zuHW zQ6 zQI^%Tc;Y-1NY3PzIea3~O)0f*FQ=qq&Th55X#Ob)9eK*2ii)`F`68ZJwrEMq*6Kj)(g1hzJB8%xpr&wNinfq&*$%`AFp5XmKFRDMb**?+_Fbwvf=Kh?V^6=@GvI6tAZ{f~+vmnE{CE4~Qi?LR4?q>5Sj}7rJU|k%^JL=dL zxjm%VLV|e8BZVYxYoZ^Vn{Rky0^eti+&a2trAKTHs_zG`AhP)0)v>l7YtQ0cny^~& z={bY?t8oL*C_f&W5WV7y*!2l6NVrz6TuAbuhnXw5~|oUErgNQdd+Kx z#Pgcotf>B6g|=fiKU+=Xo8_JHWq0tauOi#n{0&z{JiFr_qMBr75mvEuYOYb{Ha?rC zZ|JV7{*M*kcC5%xv?ZNwiPN2!TrGTn_*(PUJ8!L7i|(_fO-Oja5VZ2q&1aX-316${ zeiW5Hm&btPAAs=d+uX+XFNwhb>4bvZ||qKvHN{B9Tv@*lgLAS z`K(4lzOtfh*Jq*S@SeNKDLP}9NjZ-_Dr=#my_B3<4iL3gw{ORu;^V$ zb8@hb?{kg^@l-@jpVEZ)F~^U;`StOmhI=tVZ`UR30^#X(1;%fs-s`DWB~~OB=qaGv zfr?L`Cxk!Wwms%7%YkwSPxtf#PqYNRQB-CS3-#<-e1!x~>g8!JITLx~=DXb)H$U-e zWY*rDUQkDO(hFS=x%8syqr{hrBjILABsHmq*N4;JZb5cF;BXOeR+OK*NkNub#wpY* zVe?iiYF?3XEWRrRW_u!DEy**O8cR+o?*7oYExCHa_g2L=5B9YdWo10Afh|W!nXVsd z)El$qg(rG_6b}2afkAr)F~U{xj6&2lSs$;{6J=^9t24-*_EJRnR2c59VsIS0f1`i| z@^z--^cjj(mZ;*^Ww)(ZZ1X5UwV-A1Sasd7`){fFG{$;nu_5;)TBijut)=4L=(Lux z?5vE6H?e(N{%3f~A=u4t-_o~#PNLUfUG1>@_`P@Na0uDBcFUS*nSCetQ8xhsP{`rT z!tWmyavfbG0E7Q<3E&dICD4-u%*`DcEG&r9$Bx+>T3b7vwXr!Fj+#i^j7S-T14hS= zIT)gNNda~};oH@76BEK16!#rAHqLQGqDPLWr|)YYpP*;{{-KZk{lgyNW+ciW^f6%M z>FM{{-u_e$T1R71T~M;Ha2V4S&Ygv$x-;3z%F)}=(esU;U+Cj2S8m5dN5|a4&FGN< zU_c1KLisG5sQg9HZL5H{Sr0>-eG zLykW>KJd*IsE^Ny-E8x z55u=N_HgI`jz4Aa|7U*x&m6Ds*kk%J)H;CUKXCm2e{Bcm_x;CyICKEVe_;4i8vCKv z0UZDS<3Cu~52X&^`1cS0!Nz{5bO6V{@AzZb!x}y%`g;uPuI#}a6XN*y4gaCW9@B}z z(g7U*-s3-9*bk--;Q03r|KY}duyg>&zi0eM3igAc133QO;~yLx-F|LosK$C2#y#1T z8V}(3ca8sV>US`KJoEsv~hDEBu$-q zh%{x2cDn&Lguye2bM9Qk-M;eN+xU?8i4*>5y)hCJW@S^R9HtolkQe0Ho3bHJf8{XJ z@IQ3O?G=h`E8skFqCuzfWM(!cv9cPp>jlKs(sFy%TTBNl-um^H#gxV$@*FIBv3y4o z{>sXB@1f0`G)b>h+&gP4A?~zk2T413+P%kk8V(!Bzw>caZ?S+n4A^&7=R3n53xhPE z16o?{!`2Sq`1c=w=`aGn{QHML%PRjKm1`$57u@t{03@6UXUjx zFzrhoIR5>^f3(VtTsKB5AH%)=Gg{?_n+V zJ|OaW59}p^Y{>f=O8fEpkJo>^{tsLSMr%8U*Z+ZI4{b7zfB)n-TIELG506$p@cQ3B zy~XkWyXA)CkK^A_2k`d4fBG<5<<=2^z;2y1f;QfF9^cKhe@0J^mKaPJ#9l+cF{^`SLm0L&b!97~}!2AFH=`D``-z_&B ze;oggI)JzT{nH0Z%Wd-H!=$xqkA3cl%}}_}GQU9CxcK4;JxUYJ%$;+X%|4+jo?tu^#)UBtqoO;R+${fi373A#%2Zt6( zNy#p+r*a4S{7=K5g~hP_-n4*#tMLPsO>fg*ym%!Z?yiBd$#LwjAMTxm?}Yc3hW^I; z)9?p*!yNzy2A-Myl}TUI>FIf9QtA!^OkW^BV`HyD|Ni!$h(Ce-);Kc)lF4nSWpknsSdJ$TS18}Jzn z?15I8M@UK@8M5`uzX5-cEA$0$M=*>D`!laPc{1=mq=o*T(rrFg_hH@#`5PFV944y$8h)c=&Jw^w)4QswiN+$+=&A+2g z{Ehf`lsl}oz`hOi=YTU9a6=d+*Yi7;*_j)@X94#h`}svBcEo3}-G4j&onbK?VNmxK z6bK&&8*lRbar`^Ke`zRn0NR>avkv`)@di0QgYC!hA71=_f4l7<>A(T!*Mp69XZhp! z4=?^Or&zq$u=?`l+qXNzXRyO?{D%*JX!qfpH69+JDT9r3SNY@k4-fvo_oxq&w6%xt zn^m9_IQ~PA|M>C7BrYxulC13DpYQFe-eci7{zHpD_Ra3)%S|dGBX17+`3?-{u529t zp~D};9>#kiA=d`=`^8=1O&&hn_*44(FGD$R?@L+G=EE8fU>_KGbx8Jh$+7OtKiv3J z`uk6P!DOi7!dMT!&kk+=NU8UjE(|yRl>GkNP~q8^a$t@J`_3@d8wveAhC5~tH~y6T z{$pQo8SJ>w*TdQl%&-vAtJYOAZlLtj6fYdhpv zcjg}n_@6zCegC=tXDQ))4=3lLK|7n%6Xx(7&IQ33Z|2NHEwG+HTx+_W;oMc&NWdRx z)6{g!f_}0;-vNd+a8^TF`si2q{x4tvbr8-|^#-?r#DOw?>z-!&{ypsB4#z%TSNQf6 zK2q=pI(O`_%L5$%dG;2Q-{ULwf4}+a>l>8_XK2uKRL$7g9ncRrSKZq@dlL`x#Lf`G znJp+2%I-MEo(HK*q2>0Bc0BP6)CGx0#t&MrdgN*H6*+ zDq~$-g6dT)-FJAxg`j6NDzgGwF`v3Q04}BSqKaM}%{{Ou? zfY<-O7kj+@|GTm80snA+|Io*O+I$!1=f64+7^D4vI70Tbws!LN@d+A|JIHWM{)@>_ z%EQMe_!)}zSu*&MW5?``JU#t}<(=fA=T)7=XBzojnS~^IR<8N;6$Y^Wp9EXnqhOccNEZnht{ro~7+uELtwX(8f zAjcp4XuF{7=;-+-D(dElcvlUh8@C6plTlGO;s9F<3*wH>u!k@L!F7z4mE(DL_w#R{ zt-$3zaJi2J8A6{9n4;L8Gch5I=?Z@cx3qMejE)bZAU@s>jDGw9L%A&W z05;x$70l-WyUsAh!*B`U62K(@5+L>T`>&Wtt^f5)HT~DmQ}bW{gu?&&>B;@6|9wt_ z`~M>lTps@s2ZGB3mq$kl;QG)}8VFuLQb(CQ3ja$lJ-MH}KPB&P$@^o{fAj09cPH_q z@ug8iKn)ebi~dJ{(g<>$=C^70d% z1McYF)omj2!b7kJBEyJ?>+#2ryO?48qje){Ao&eN+RK-3-Cnci*cZ6FV)g1{U%)?* za-luI_=mP`AbAaTT8#IJ6Ajz%et|nYWMnJ`Bm>~N1OFrD9#3*vlJkz;X){n6K)*K9 z`R|H%O2}ZO^WPKi1C_x@=D)YR4@3qdm4A$PsNWcWy|Me+9*ji(`^!5fpT5Xor13vc z-uo(pk;Fgn4r7|W>O96Xx!oH*7)kuYokf)59g_v*2lSY-nQ#Xfx#8o}`HAtz~!?GeuZptVhy zoN)d}Zu>u&*x>m8K9@R}G9GCEKbW-P_~ZD44h*J_;rc(Y_20q72Cx5k{m1M7pV9u! zn`c;u)#tw2kQSRqb!Q6;_kE5vRP8?(mriR`RNwb%(KIoTwmZJDXX4^!-})-sq2eFD z>tSW(^Qf=%Sz7u$hO`)u-P!BcTNd{<|Doa^*1?u7Gppz;eUPTBwY842m@vVJq^9Qd zrmy)A75_jhd{3z_>tGrhuGx^LBcENlK}YQDE<7B``~&TiC+q$~`RZ{k5aa}LyW*ET zoR?Rx8TL;`I{!d7UBX=XQ6F} z{tXPcA*?s`y<=XJC+qw=bt*KaJ3hO5HeCF7q<dzPfz)yGw7uEa?~dn= z&j!kWZ}-2*dFZbnbRKZ;Pha1WZ_GVV{$VWB)Bb0FdF_}E+WBeIbXx{m-!Z;1d!YRH zX8#*Km(UD-H1N=or{1_B4WxxWZXoSE#xJ=&Q2rsm|3Clm-?M6<7lMMn&p*KU1jcB* zyt>Vx!!Tdzihpk><{R45syM7(te?#vOgZCf%wT?6pI)*Fl z@An^x|GxhS?Eree(%X-V2Al{|xs;!u!vEjDa=|-gAX_eKCG<`~U9= zD*P=B`qbYx4dJ&

0d81-^U{f#G0;cuHByFdc&i4Z}}KGghsPsFV5?}K$J;yLc3@yca!^p@h%`VO&M zUWpeHGOb}@F(R#BZ}X`yJOIt;y}x|u_+}31`%uu^v5bI#tL?O7dz-Mvw{D%yr{3P- z05k*b!$CjLzF>iA4Msb*ZV79%z(a3v7%uuTogt?k;{kB!jV=ub{p31>(M)dlMwf<~ z{;qU}oOaCLlP(P<{XOXnMl-qHoh}U({gIJ3V}ylGtGm*7a+=Bgz=NFJ@&9>Ww!icf z2!81j5|*WN=bF@sikg4Z(K+>^yEYHn5;*s?cdyeM%q(1JQcX@L<_Eo5v&P~J78h(& zQ?HDUZ7;a{L%*J$XXd0y2LCyO1oDxUCG=_EdHs6ZB8*;g8|a5}$mu2b_m%#jpy)f? z+yY;h-Nd1#B^T$!(yCoX)QN zIQ@OEzqtJW|C_W{elD;wf;H;&@qFlY@4xz^*pHKpkX`LVJZ{!&+W$?l4Fcm{DW zS$AfOiJAB6otVL(ALxOzq)V0@tuQt|^X&BLuw*bsMrWQu7(DBYW-JVF>Fs&M!J;3d z1?nNZGXbW%c>@+7Y(752#!HuO#CC;W$MC_Xzaw3}al;xY%%wZh*qwVM(GP1Al9HCC z-K_(V^DeWkyr`EPG@ggg$^*F}rWssaM8^!aY*5l0uOmHu zb@x^NSpMCuzxnz`C2DJXyoB-D!i7gGVT}$9xM5u%o0>A~W8%ZP40Bt}coY#OeCqR40ALtV7$)o4(W{*tc_&?`o3V{Lo-`|r# z4+^z^R|ahSM_CCFAiECoEd)O*cQ~iTiyCRv{CiG|rSo%IsD9C*-*{e+p5Ig%3Ik=K z-<6?5Nki)>kq*^;pQ~2~_Kl9-v~5@%C=1F&zi9$|Z=iJ9Zf+r|yLUV1UcK7&SOChz zWRB5A2^-4h;xcK1`PascCkncw0mGUSr44icE|kK*^OGypO5Tvo^|k`M^?x9;O6EwY3f*JM|V&B z1K;HNX=|VA@*V+7mk+d)mk(uhv{bxpIpXW`B$#ADIu3rNB>}!KW&;xqn}??LdWvR-944x zS^v`&uN}joY**LdRPyr3{XLaW-v6?)8vp8U&j_BaTxng>6W+8!GIgWy3;kR%#LH)Zh{zpK|hII;bS)zTd;@@=&+W%dr@bbDS5E63P4mFN_ zGn-q#k39D;?0|#{_S_s^iE(pOM`_O8~Z%P z(qe77oZRm`$kTgxgeUXxnAZynTYtm$`C$La*!b)-EbW#pP9M&k32*3&<|M$EEdHRm>$I@Un=zy7-?-Q(Su*uW^ zgMWjNko7ll@#Cdar - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/nw/assets/icons/x-novelwriter-project.ico b/nw/assets/icons/x-novelwriter-project.ico new file mode 100644 index 0000000000000000000000000000000000000000..b349aa305015d03309747d7915eff3a61c429e41 GIT binary patch literal 14637 zcmXY22Rzl^|Nh)--OOu*kiEA^$hd@z$jUCWtg@V?v8X_?ni0$M=fOpyl5O3Cx#}j*eXu?Os?>9&J9z# zuJGl)@Xb79WpVW%(LDJoUfJAEi{sI}jTEcrmU@}v2^*D}?PPB?-gmAs_MZ7G?Xbhh zBvI3tRN*;>#!6$Tvaz!9^#Sj1z0QiJwaxX9n?AocYv*cH$gpM6VPW0=fuoHuc6Rnt zGkkPdH#~Tx5#}7)wN&T4f*%|lEO_URe|fo(e3m9F*^M2^QrG=Q2>(pZ;nC4WKRkM< zhbGf=17D$l1ikA1NyeA{;^KmunpzOAL0MVsv%2K@PJ0f5!cPn~?@rI5{t2_t9wH

D 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 literal 0 HcmV?d00001 diff --git a/nw/assets/icons/x-novelwriter-project.svg b/nw/assets/icons/x-novelwriter-project.svg new file mode 100644 index 00000000..f314187f --- /dev/null +++ b/nw/assets/icons/x-novelwriter-project.svg @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/setup/icons/novelwriter.ico b/setup/icons/novelwriter.ico index c96e8bdbc7fec644c2a5697baf9e017d6b6a15f6..df88329643ac837eb41542af01a6e0d608abf044 100644 GIT binary patch delta 32 jcmexAoqc&PqbLIdBM^cBBZC6NL^lQoF$RW>sgv9QXm|zY literal 117759 zcmY(qcRbtQ7xS#k_4^2S5Zw-wDzo;L2Fix zQms-{DN1YAsP)a~@%{e(_~r3P^18|6-urspd(XM&JkPlR01yBH{O<(>paE$*0DuSW znn?V=WpOAFa6tPFhx>nJO#ndf1`vS7{@-%B2mqj)MyrYbe`N#!@TVCFK+wMb*Pa&` z0LbYF0^&?fbeZA2a9Y#M`g&UC|NHlUZ|MKJb_gz~^{Hd4uZ6d~H?!H{UF&WWCU8>O z)PL#QOR=U&85j)q1x4cXd;i$AmcM{Q;wuFciEK-^JX2B8zmjF(=3*nsDYjtwD4u=5 z^vTd&^B@qkAo?v@h@DOWGRy>mA$$}=D%z%&Iv)PH*8Ayvt#uBd}vjNa%~^4Z)M&8?}Lv%^%Z0IVb0lzuN|sAhSbSI2&F6OrJjU^h9oIv zGlNSQYOfIsF2)uzqr!GxYixb{`KqcIboFGQE5btW^C+_RF|gptuvb-G-#FEfaEL8F z{F`!Gd=T-*&-n#(VNq9oZ;8#h3%sDaL(l#>n_g2wT!^sCE881i;VYIAQPo(Dr04uF zlnI=gcG||6bJ>^)4mYOC6Vvo857-HNHUg*A(TA;G%rVe~va{a|qo$7{fsaDPdLK?- z@Ad5obx>&@mU$XEoW{Qjjq{+4`b6l$wUqF! z9G>p^e!X7ZVPEHpHeiCiJ>B4DqAc5hXu86!E5f;5$`oBthPb|Vr4CZ>Cm~%Wf-@_P zX!flL?2D^bA_-{{uSdJ3@ecbBq#ZGM#JX+A8>y`rmUT~<*@hd*8`)@vz z=a(26q}k-y$Vtg27Vy&D#-iV(TUhcN z;RM0{$gRg*abml{>IL)rS&XgnPl``_lQD?y;|Pu&kun$T3u0n}xUn*son&>~TxL&2 zQg58ju@*jO>jV*oi#W>en)KB1SK-sO^KuzPOYz>q3>8r8 zi%As7++;DebIx3J0(=Gm?r zHJD@&(e9ags)1$9Sy@Dfj>-e7d z=i7bK$SLDqD4Rd1iAcompa6{*Wca?$j{6YvPF3O`PVsh2Hp8uKXDPb{ek>`IC4*q zNLC0qS?>RK8XB>QkC1eHEPS%j@^{W!E#NVi5S?=;Qj2(E7#WBa0$^QEndF$~!RW(F zfQ2@7zl5bjcE~_lUak@X-`O6{%gHub%8bebKUuS%#hAkC-6mcYMAd&-O9OeB)A?^f@r$GNLM6OMWuxi;UM;KHSM;c!RR&V_EeJ2<=7IB#qVzCL=HQoSGeYsfumoNh_B%0DrH@b;RqP%`)Lf& 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

D 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 literal 0 HcmV?d00001 diff --git a/setup/win_setup.iss b/setup/win_setup.iss index 8b6be609..ee4c8439 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" @@ -46,7 +47,19 @@ Source: "{#nwAppDir}\novelWriter\*"; DestDir: "{app}"; Flags: ignoreversion recu [Icons] Name: "{autoprograms}\{#nwAppName}"; Filename: "{app}\{#nwAppExeName}" Name: "{autodesktop}\{#nwAppName}"; Filename: "{app}\{#nwAppExeName}"; Tasks: desktopicon +Name: "{autodesktop}\{#nwAppName}"; Filename: "{app}\assets\icons\x-novelwriter-project.ico"; Tasks: icon Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#nwAppName}"; Filename: "{app}\{#nwAppExeName}"; Tasks: quicklaunchicon [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 +; ".myp" is the extension we're associating. "MyProgramFile.myp" is the internal name for the file type as stored in the registry. Make sure you use a unique name for this so you don't inadvertently overwrite another application's registry key. +Root: HKA; Subkey: "Software\Classes\novelWriterProject.nwx"; ValueType: string; ValueName: ""; ValueData: "novelWriter Project File"; Flags: uninsdeletekey +; "My Program File" above is the name for the file type as shown in Explorer. +Root: HKA; Subkey: "Software\Classes\novelWriterProject.nwx\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\novelWriter.exe,2" +; "DefaultIcon" is the registry key that specifies the filename containing the icon to associate with the file type. ",0" tells Explorer to use the first icon from MyProg.exe. (",1" would mean the second icon.) +Root: HKA; Subkey: "Software\Classes\novelWriterProject.nwx\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\novelWriter.exe"" ""%1""" +; "shell\open\command" is the registry key that specifies the program to execute when a file of the type is double-clicked in Explorer. The surrounding quotes are in the command line so it handles long filenames correctly. +Root: HKA; Subkey: "Software\Classes\Applications\novelWriter.exe\SupportedTypes"; ValueType: string; ValueName: ".nwx"; ValueData: "" \ No newline at end of file From eb35e4d6429327725e088cfa8c8d0ba07d9d605f Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 29 Nov 2020 16:24:35 +0100 Subject: [PATCH 10/52] Cleanup and registry fixes for mime icon --- setup/win_setup.iss | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/setup/win_setup.iss b/setup/win_setup.iss index ee4c8439..ba2c9990 100644 --- a/setup/win_setup.iss +++ b/setup/win_setup.iss @@ -47,7 +47,6 @@ Source: "{#nwAppDir}\novelWriter\*"; DestDir: "{app}"; Flags: ignoreversion recu [Icons] Name: "{autoprograms}\{#nwAppName}"; Filename: "{app}\{#nwAppExeName}" Name: "{autodesktop}\{#nwAppName}"; Filename: "{app}\{#nwAppExeName}"; Tasks: desktopicon -Name: "{autodesktop}\{#nwAppName}"; Filename: "{app}\assets\icons\x-novelwriter-project.ico"; Tasks: icon Name: "{userappdata}\Microsoft\Internet Explorer\Quick Launch\{#nwAppName}"; Filename: "{app}\{#nwAppExeName}"; Tasks: quicklaunchicon [Run] @@ -55,11 +54,7 @@ Filename: "{app}\{#nwAppExeName}"; Description: "{cm:LaunchProgram,{#StringChang [Registry] Root: HKA; Subkey: "Software\Classes\.nwx\OpenWithProgids"; ValueType: string; ValueName: "novelWriterProject.nwx"; ValueData: ""; Flags: uninsdeletevalue -; ".myp" is the extension we're associating. "MyProgramFile.myp" is the internal name for the file type as stored in the registry. Make sure you use a unique name for this so you don't inadvertently overwrite another application's registry key. Root: HKA; Subkey: "Software\Classes\novelWriterProject.nwx"; ValueType: string; ValueName: ""; ValueData: "novelWriter Project File"; Flags: uninsdeletekey -; "My Program File" above is the name for the file type as shown in Explorer. -Root: HKA; Subkey: "Software\Classes\novelWriterProject.nwx\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\novelWriter.exe,2" -; "DefaultIcon" is the registry key that specifies the filename containing the icon to associate with the file type. ",0" tells Explorer to use the first icon from MyProg.exe. (",1" would mean the second icon.) +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""" -; "shell\open\command" is the registry key that specifies the program to execute when a file of the type is double-clicked in Explorer. The surrounding quotes are in the command line so it handles long filenames correctly. -Root: HKA; Subkey: "Software\Classes\Applications\novelWriter.exe\SupportedTypes"; ValueType: string; ValueName: ".nwx"; ValueData: "" \ No newline at end of file +Root: HKA; Subkey: "Software\Classes\Applications\novelWriter.exe\SupportedTypes"; ValueType: string; ValueName: ".nwx"; ValueData: "" From f943b956f0ecec888ffd55bd47c7f0885df2d9a5 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 29 Nov 2020 17:01:25 +0100 Subject: [PATCH 11/52] Remove windows line endings from SVG files --- nw/assets/icons/novelwriter.svg | 368 +++++++++---------- nw/assets/icons/x-novelwriter-project.svg | 414 +++++++++++----------- 2 files changed, 391 insertions(+), 391 deletions(-) diff --git a/nw/assets/icons/novelwriter.svg b/nw/assets/icons/novelwriter.svg index 36880a7f..18be89c1 100644 --- a/nw/assets/icons/novelwriter.svg +++ b/nw/assets/icons/novelwriter.svg @@ -1,184 +1,184 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/nw/assets/icons/x-novelwriter-project.svg b/nw/assets/icons/x-novelwriter-project.svg index f314187f..08546d15 100644 --- a/nw/assets/icons/x-novelwriter-project.svg +++ b/nw/assets/icons/x-novelwriter-project.svg @@ -1,207 +1,207 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + From 6a1dc8b5ae6b077d0e9adb7b6708cc3c1bb07fa8 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Mon, 30 Nov 2020 22:28:01 +0100 Subject: [PATCH 12/52] New test for NWItem class --- nw/core/item.py | 14 +- tests/conftest.py | 271 +++++++++++----------- tests/{test_item.py => test_core_item.py} | 190 +++++++++++---- 3 files changed, 300 insertions(+), 175 deletions(-) rename tests/{test_item.py => test_core_item.py} (65%) diff --git a/nw/core/item.py b/nw/core/item.py index 6793af39..ead16ab8 100644 --- a/nw/core/item.py +++ b/nw/core/item.py @@ -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/tests/conftest.py b/tests/conftest.py index b8cc2fef..97aafd7d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ import os from nwdummy import DummyMain -from PyQt5.QtWidgets import QMessageBox +# from PyQt5.QtWidgets import QMessageBox sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir))) @@ -20,7 +20,7 @@ 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. @@ -33,170 +33,179 @@ def nwTemp(): os.mkdir(tempDir) return tempDir -@pytest.fixture(scope="session") -def nwRef(): - """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 +# @pytest.fixture(scope="session") +# def nwRef(): +# """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 ## +# @pytest.fixture(scope="session") +# def tmpConf(nwTemp): +# """Create a temporary novelWriter configuration object. +# """ +# theConf = Config() +# theConf.initConfig(nwTemp, nwTemp) +# theConf.setLastPath("") +# return theConf + @pytest.fixture(scope="session") -def tmpConf(nwTemp): +def tmpConf(tmpDir): """Create a temporary novelWriter configuration object. """ theConf = Config() - theConf.initConfig(nwTemp, nwTemp) + theConf.initConfig(tmpDir, tmpDir) 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 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): +def dummyGUI(tmpConf): """Create a dummy instance of novelWriter's main GUI class. """ theDummy = DummyMain() - theDummy.mainConf = nwConf + theDummy.mainConf = tmpConf 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 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 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 +# @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 -@pytest.fixture(scope="function") -def nwFuncTemp(nwTemp): - """A temporary folder for a single test function. - """ - funcDir = os.path.join(nwTemp, "ftemp") - if os.path.isdir(funcDir): - shutil.rmtree(funcDir) - if not os.path.isdir(funcDir): - os.mkdir(funcDir) - yield funcDir - if os.path.isdir(funcDir): - shutil.rmtree(funcDir) - return +# @pytest.fixture(scope="function") +# def nwFuncTemp(nwTemp): +# """A temporary folder for a single test function. +# """ +# funcDir = os.path.join(nwTemp, "ftemp") +# if os.path.isdir(funcDir): +# shutil.rmtree(funcDir) +# if not os.path.isdir(funcDir): +# os.mkdir(funcDir) +# yield funcDir +# if os.path.isdir(funcDir): +# shutil.rmtree(funcDir) +# return ## # Temp Folders for Projects ## -@pytest.fixture(scope="function") -def nwMinimal(nwTemp): - """A minimal novelWriter example project. - """ - testDir = os.path.dirname(__file__) - minimalStore = os.path.join(testDir, "minimal") - minimalDir = os.path.join(nwTemp, "minimal") - if os.path.isdir(minimalDir): - shutil.rmtree(minimalDir) - shutil.copytree(minimalStore, minimalDir) - cacheDir = os.path.join(minimalDir, "cache") - if os.path.isdir(cacheDir): - shutil.rmtree(cacheDir) - metaDir = os.path.join(minimalDir, "meta") - if os.path.isdir(metaDir): - shutil.rmtree(metaDir) - yield minimalDir - if os.path.isdir(minimalDir): - shutil.rmtree(minimalDir) - return +# @pytest.fixture(scope="function") +# def nwMinimal(nwTemp): +# """A minimal novelWriter example project. +# """ +# testDir = os.path.dirname(__file__) +# minimalStore = os.path.join(testDir, "minimal") +# minimalDir = os.path.join(nwTemp, "minimal") +# if os.path.isdir(minimalDir): +# shutil.rmtree(minimalDir) +# shutil.copytree(minimalStore, minimalDir) +# cacheDir = os.path.join(minimalDir, "cache") +# if os.path.isdir(cacheDir): +# shutil.rmtree(cacheDir) +# metaDir = os.path.join(minimalDir, "meta") +# if os.path.isdir(metaDir): +# shutil.rmtree(metaDir) +# yield minimalDir +# if os.path.isdir(minimalDir): +# shutil.rmtree(minimalDir) +# return -@pytest.fixture(scope="function") -def nwLipsum(nwTemp): - """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") - if os.path.isdir(lipsumDir): - shutil.rmtree(lipsumDir) - shutil.copytree(lipsumStore, lipsumDir) - cacheDir = os.path.join(lipsumDir, "cache") - if os.path.isdir(cacheDir): - shutil.rmtree(cacheDir) - metaDir = os.path.join(lipsumDir, "meta") - if os.path.isdir(metaDir): - shutil.rmtree(metaDir) - yield lipsumDir - if os.path.isdir(lipsumDir): - shutil.rmtree(lipsumDir) - return +# @pytest.fixture(scope="function") +# def nwLipsum(nwTemp): +# """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") +# if os.path.isdir(lipsumDir): +# shutil.rmtree(lipsumDir) +# shutil.copytree(lipsumStore, lipsumDir) +# cacheDir = os.path.join(lipsumDir, "cache") +# if os.path.isdir(cacheDir): +# shutil.rmtree(cacheDir) +# metaDir = os.path.join(lipsumDir, "meta") +# if os.path.isdir(metaDir): +# shutil.rmtree(metaDir) +# yield lipsumDir +# if os.path.isdir(lipsumDir): +# shutil.rmtree(lipsumDir) +# return -@pytest.fixture(scope="function") -def nwOldProj(nwTemp): - """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") - if os.path.isdir(oldProjDir): - shutil.rmtree(oldProjDir) - shutil.copytree(oldProjStore, oldProjDir) - yield oldProjDir - if os.path.isdir(oldProjDir): - shutil.rmtree(oldProjDir) - return +# @pytest.fixture(scope="function") +# def nwOldProj(nwTemp): +# """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") +# if os.path.isdir(oldProjDir): +# shutil.rmtree(oldProjDir) +# shutil.copytree(oldProjStore, oldProjDir) +# yield oldProjDir +# if os.path.isdir(oldProjDir): +# shutil.rmtree(oldProjDir) +# return ## # Monkey Patch Dialogs ## -@pytest.fixture(scope="function") -def yesToAll(monkeypatch): - """Make the message boxes/questions always say yes. - """ - monkeypatch.setattr( - QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes - ) - monkeypatch.setattr( - QMessageBox, "information", lambda *args, **kwargs: QMessageBox.Yes - ) - monkeypatch.setattr( - QMessageBox, "warning", lambda *args, **kwargs: QMessageBox.Yes - ) - monkeypatch.setattr( - QMessageBox, "critical", lambda *args, **kwargs: QMessageBox.Yes - ) - return +# @pytest.fixture(scope="function") +# def yesToAll(monkeypatch): +# """Make the message boxes/questions always say yes. +# """ +# monkeypatch.setattr( +# QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes +# ) +# monkeypatch.setattr( +# QMessageBox, "information", lambda *args, **kwargs: QMessageBox.Yes +# ) +# monkeypatch.setattr( +# QMessageBox, "warning", lambda *args, **kwargs: QMessageBox.Yes +# ) +# monkeypatch.setattr( +# QMessageBox, "critical", lambda *args, **kwargs: QMessageBox.Yes +# ) +# return 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..a144b898 100644 --- a/tests/test_item.py +++ b/tests/test_core_item.py @@ -9,10 +9,11 @@ from lxml import etree from nw.core.project import NWProject, NWItem from nw.constants import nwItemClass, nwItemType, nwItemLayout -@pytest.mark.project -def testItemSettersSimple(nwDummy): - - theProject = NWProject(nwDummy) +@pytest.mark.core +def testCoreItemSetters(dummyGUI): + """Test all the simple setter classes for the NWItem class. + """ + theProject = NWProject(dummyGUI) theItem = NWItem(theProject) # Name @@ -20,12 +21,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 +41,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 +66,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 +93,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 +139,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 testCoreItemSetters + +@pytest.mark.core +def testCoreItemTypeSetter(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 testCoreItemTypeSetter + +@pytest.mark.core +def testCoreItemClassSetter(dummyGUI): + """Test the setter for all the nwItemClass values for the NWItem + class. + """ + theProject = NWProject(dummyGUI) theItem = NWItem(theProject) # Class @@ -138,33 +209,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 testCoreItemClassSetter - 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 testCoreItemLayoutSetter(dummyGUI): + """Test the setter for all the nwItemLayout values for the NWItem + class. + """ + theProject = NWProject(dummyGUI) theItem = NWItem(theProject) # Layout @@ -190,14 +245,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 testCoreItemLayoutSetter - theProject = NWProject(nwDummy) - theItem = NWItem(theProject) +@pytest.mark.core +def testCoreItemXMLPackUnpack(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 +269,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 +282,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 +302,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 +378,5 @@ def testItemXMLPackUnpack(nwDummy): assert etree.tostring(xDummy, pretty_print=False, encoding="utf-8") == ( b"" ) + +# END Test testCoreItemXMLPackUnpack From dd943116afab11f8ec81090324aefade9930cc70 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Mon, 30 Nov 2020 22:46:55 +0100 Subject: [PATCH 13/52] New test for core tools functions --- nw/core/tools.py | 20 ++++-- tests/test_core_item.py | 22 +++---- tests/{test_tools.py => test_core_tools.py} | 71 ++++++++++++--------- 3 files changed, 68 insertions(+), 45 deletions(-) rename tests/{test_tools.py => test_core_tools.py} (85%) 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/tests/test_core_item.py b/tests/test_core_item.py index a144b898..e1457792 100644 --- a/tests/test_core_item.py +++ b/tests/test_core_item.py @@ -10,8 +10,8 @@ from nw.core.project import NWProject, NWItem from nw.constants import nwItemClass, nwItemType, nwItemLayout @pytest.mark.core -def testCoreItemSetters(dummyGUI): - """Test all the simple setter classes for the NWItem class. +def testCoreItem_Setters(dummyGUI): + """Test all the simple setters for the NWItem class. """ theProject = NWProject(dummyGUI) theItem = NWItem(theProject) @@ -144,10 +144,10 @@ def testCoreItemSetters(dummyGUI): theItem.saveInitialCount() assert theItem.initCount == 234 -# END Test testCoreItemSetters +# END Test testCoreItem_Setters @pytest.mark.core -def testCoreItemTypeSetter(dummyGUI): +def testCoreItem_TypeSetter(dummyGUI): """Test the setter for all the nwItemType values for the NWItem class. """ @@ -172,10 +172,10 @@ def testCoreItemTypeSetter(dummyGUI): theItem.setType(nwItemType.ROOT) assert theItem.itemType == nwItemType.ROOT -# END Test testCoreItemTypeSetter +# END Test testCoreItem_TypeSetter @pytest.mark.core -def testCoreItemClassSetter(dummyGUI): +def testCoreItem_ClassSetter(dummyGUI): """Test the setter for all the nwItemClass values for the NWItem class. """ @@ -212,10 +212,10 @@ def testCoreItemClassSetter(dummyGUI): theItem.setClass(nwItemClass.NOVEL) assert theItem.itemClass == nwItemClass.NOVEL -# END Test testCoreItemClassSetter +# END Test testCoreItem_ClassSetter @pytest.mark.core -def testCoreItemLayoutSetter(dummyGUI): +def testCoreItem_LayoutSetter(dummyGUI): """Test the setter for all the nwItemLayout values for the NWItem class. """ @@ -248,10 +248,10 @@ def testCoreItemLayoutSetter(dummyGUI): theItem.setLayout(nwItemLayout.NOTE) assert theItem.itemLayout == nwItemLayout.NOTE -# END Test testCoreItemLayoutSetter +# END Test testCoreItem_LayoutSetter @pytest.mark.core -def testCoreItemXMLPackUnpack(dummyGUI): +def testCoreItem_XMLPackUnpack(dummyGUI): """Test packing and unpacking XML objects for the NWItem class. """ theProject = NWProject(dummyGUI) @@ -379,4 +379,4 @@ def testCoreItemXMLPackUnpack(dummyGUI): b"" ) -# END Test testCoreItemXMLPackUnpack +# END Test testCoreItem_XMLPackUnpack diff --git a/tests/test_tools.py b/tests/test_core_tools.py similarity index 85% rename from tests/test_tools.py rename to tests/test_core_tools.py index 4832aadb..ceb146a4 100644 --- a/tests/test_tools.py +++ b/tests/test_core_tools.py @@ -7,7 +7,7 @@ import pytest from nw.core.tools import countWords, numberToRoman, numberToWord @pytest.mark.core -def testCountWords(): +def testCoreTools_CountWords(): """Test the word counter and the exclusion filers. """ testText = ( @@ -26,15 +26,46 @@ def testCountWords(): "\n" "\n" "The third paragraph.\n" + "\n" + "Dashes\u2013and even longer\u2014dashes." ) cC, wC, pC = countWords(testText) - assert cC == 108 - assert wC == 17 - assert pC == 3 + assert cC == 138 + assert wC == 22 + assert pC == 4 + +# END Test testCoreTools_CountWords @pytest.mark.core -def testNumberWords(): +def testCoreTools_RomanNumbers(): + """Test conversion of integers to Roman numbers. + """ + assert numberToRoman(None, False) == "NAN" + assert numberToRoman(0, False) == "OOR" + assert numberToRoman(1, False) == "I" + assert numberToRoman(2, False) == "II" + assert numberToRoman(3, False) == "III" + assert numberToRoman(4, False) == "IV" + assert numberToRoman(5, False) == "V" + assert numberToRoman(6, False) == "VI" + assert numberToRoman(7, False) == "VII" + assert numberToRoman(8, False) == "VIII" + assert numberToRoman(9, False) == "IX" + assert numberToRoman(10, False) == "X" + assert numberToRoman(14, False) == "XIV" + assert numberToRoman(42, False) == "XLII" + assert numberToRoman(99, False) == "XCIX" + assert numberToRoman(142, False) == "CXLII" + assert numberToRoman(542, False) == "DXLII" + assert numberToRoman(999, False) == "CMXCIX" + assert numberToRoman(2010, False) == "MMX" + assert numberToRoman(999, True) == "cmxcix" + +# END Test testCoreTools_RomanNumbers + +@pytest.mark.core +def testCoreTools_NumberWords(): """Test the conversion of integer to English words. """ assert numberToWord(0, "en") == "Zero" @@ -70,27 +101,9 @@ def testNumberWords(): assert numberToWord(2, "foo") == "Two" assert numberToWord(3, "foo") == "Three" -@pytest.mark.core -def testRomanNumbers(): - """Test conversion of integers to Roman numbers. - """ - assert numberToRoman(None, False) == "NAN" - assert numberToRoman(0, False) == "OOR" - assert numberToRoman(1, False) == "I" - assert numberToRoman(2, False) == "II" - assert numberToRoman(3, False) == "III" - assert numberToRoman(4, False) == "IV" - assert numberToRoman(5, False) == "V" - assert numberToRoman(6, False) == "VI" - assert numberToRoman(7, False) == "VII" - assert numberToRoman(8, False) == "VIII" - assert numberToRoman(9, False) == "IX" - assert numberToRoman(10, False) == "X" - assert numberToRoman(14, False) == "XIV" - assert numberToRoman(42, False) == "XLII" - assert numberToRoman(99, False) == "XCIX" - assert numberToRoman(142, False) == "CXLII" - assert numberToRoman(542, False) == "DXLII" - assert numberToRoman(999, False) == "CMXCIX" - assert numberToRoman(2010, False) == "MMX" - assert numberToRoman(999, True) == "cmxcix" + # Test out of range values + assert numberToWord(12345, "en") == "[Out of Range]" + assert numberToWord(-2345, "en") == "[Negative]" + assert numberToWord("234", "en") == "[NaN]" + +# END Test testCoreTools_NumberWords From 2a642ea40551c7677c2b2977161ab4966b6a7284 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 1 Dec 2020 01:15:06 +0100 Subject: [PATCH 14/52] New test for NWTree class --- nw/core/tree.py | 21 +- tests/test_core_tree.py | 437 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 452 insertions(+), 6 deletions(-) create mode 100644 tests/test_core_tree.py diff --git a/nw/core/tree.py b/nw/core/tree.py index ac3ab45c..b76b6678 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, generating new" % tHandle) + tHandle = self._makeHandle() + 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,7 +107,10 @@ class NWTree(): self._trashRoot = tHandle else: logger.error("Only one trash folder allowed") + return + self._projTree[tHandle] = nwItem + self._treeOrder.append(tHandle) self._setTreeChanged(True) return @@ -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/tests/test_core_tree.py b/tests/test_core_tree.py new file mode 100644 index 00000000..1eff405d --- /dev/null +++ b/tests/test_core_tree.py @@ -0,0 +1,437 @@ +# -*- coding: utf-8 -*- +"""novelWriter NWTree Class Tester +""" + +import os +import pytest + +from lxml import etree + +from nw.core.project import NWProject, NWItem, NWTree +from nw.constants import nwItemClass, nwItemType, nwItemLayout, nwFiles + +@pytest.fixture(scope="session") +def dummyItems(dummyGUI): + """Create a list of dummy items. + """ + theProject = NWProject(dummyGUI) + + itemA = NWItem(theProject) + itemA.itemName = "Novel" + itemA.itemType = nwItemType.ROOT + itemA.itemClass = nwItemClass.NOVEL + itemA.isExpanded = True + + itemB = NWItem(theProject) + itemB.itemName = "Act One" + itemB.itemType = nwItemType.FOLDER + itemB.itemClass = nwItemClass.NOVEL + itemB.isExpanded = True + + itemC = NWItem(theProject) + itemC.itemName = "Chapter One" + itemC.itemType = nwItemType.FILE + itemC.itemClass = nwItemClass.NOVEL + itemC.itemLayout = nwItemLayout.CHAPTER + itemC.charCount = 300 + itemC.wordCount = 50 + itemC.paraCount = 2 + + itemD = NWItem(theProject) + itemD.itemName = "Scene One" + itemD.itemType = nwItemType.FILE + itemD.itemClass = nwItemClass.NOVEL + itemD.itemLayout = nwItemLayout.SCENE + itemD.charCount = 3000 + itemD.wordCount = 500 + itemD.paraCount = 20 + + itemE = NWItem(theProject) + itemE.itemName = "Outtakes" + itemE.itemType = nwItemType.ROOT + itemE.itemClass = nwItemClass.ARCHIVE + itemE.isExpanded = False + + itemF = NWItem(theProject) + itemF.itemName = "Trash" + itemF.itemType = nwItemType.TRASH + itemF.itemClass = nwItemClass.TRASH + itemF.isExpanded = False + + itemG = NWItem(theProject) + itemG.itemName = "Characters" + itemG.itemType = nwItemType.ROOT + itemG.itemClass = nwItemClass.CHARACTER + itemG.isExpanded = True + + itemH = NWItem(theProject) + itemH.itemName = "Jane Doe" + itemH.itemType = nwItemType.FILE + itemH.itemClass = nwItemClass.CHARACTER + itemH.itemLayout = nwItemLayout.NOTE + itemH.charCount = 2000 + itemH.wordCount = 400 + itemH.paraCount = 16 + + theItems = [ + ("a000000000001", None, itemA), + ("b000000000001", "a000000000001", itemB), + ("c000000000001", "b000000000001", itemC), + ("c000000000002", "b000000000001", itemD), + ("a000000000002", None, itemE), + ("a000000000003", None, itemF), + ("a000000000004", None, itemG), + ("b000000000002", "a000000000002", itemH), + ] + + return theItems + +@pytest.mark.core +def testCoreTree_BuildTree(dummyGUI, dummyItems): + """Test building a project tree from a list of items. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + theTree.setSeed(42) + assert theTree._handleSeed == 42 + + # Check that tree is empty (calls NWTree.__bool__) + assert not theTree + + # Check for archive and trash folders + assert theTree.trashRoot() is None + assert theTree.archiveRoot() is None + assert not theTree.isTrashRoot("a000000000003") + + aHandles = [] + for tHandle, pHande, nwItem in dummyItems: + aHandles.append(tHandle) + theTree.append(tHandle, pHande, nwItem) + + assert theTree._treeChanged + + # Check that tree is not empty (calls __bool__) + assert theTree + + # Check the number of elements (calls __len__) + assert len(theTree) == len(dummyItems) + + # Check that we have the correct handles + assert theTree.handles() == aHandles + + # Check by iterator (calls __iter__, __next__ and __getitem__) + for theItem, theHandle in zip(theTree, aHandles): + assert theItem.itemHandle == theHandle + + # Check that we have the correct archive and trash folders + assert theTree.trashRoot() == "a000000000003" + assert theTree.archiveRoot() == "a000000000002" + assert theTree.isTrashRoot("a000000000003") + + # Try to add another trash folder + itemT = NWItem(theProject) + itemT.itemName = "Trash" + itemT.itemType = nwItemType.TRASH + itemT.itemClass = nwItemClass.TRASH + itemT.isExpanded = False + + theTree.append("1234567890abc", None, itemT) + assert len(theTree) == len(dummyItems) + + # Generate handle automatically + itemT = NWItem(theProject) + itemT.itemName = "New File" + itemT.itemType = nwItemType.FILE + itemT.itemClass = nwItemClass.NOVEL + itemT.itemLayout = nwItemLayout.SCENE + + theTree.append(None, None, itemT) + assert len(theTree) == len(dummyItems) + 1 + + theList = theTree.handles() + assert theList[-1] == "73475cb40a568" + + # Duplicate handle + itemT = NWItem(theProject) + itemT.itemName = "New File" + itemT.itemType = nwItemType.FILE + itemT.itemClass = nwItemClass.NOVEL + itemT.itemLayout = nwItemLayout.SCENE + + theTree.append("73475cb40a568", None, itemT) + assert len(theTree) == len(dummyItems) + 2 + + theList = theTree.handles() + assert theList[-1] == "44cb730c42048" + + # Delete the last two items + del theTree["dummy"] + assert len(theTree) == len(dummyItems) + 2 + + del theTree["44cb730c42048"] + assert len(theTree) == len(dummyItems) + 1 + assert "44cb730c42048" not in theTree + + del theTree["73475cb40a568"] + assert len(theTree) == len(dummyItems) + assert "73475cb40a568" not in theTree + + del theTree["a000000000001"] + assert len(theTree) == len(dummyItems) - 1 + assert "a000000000001" not in theTree + + del theTree["a000000000002"] + assert len(theTree) == len(dummyItems) - 2 + assert "a000000000002" not in theTree + assert theTree.archiveRoot() is None + + del theTree["a000000000003"] + assert len(theTree) == len(dummyItems) - 3 + assert "a000000000003" not in theTree + assert theTree.trashRoot() is None + +# END Test testCoreTree_BuildTree + +@pytest.mark.core +def testCoreTree_Methods(dummyGUI, dummyItems): + """Test building a project tree from a list of items. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + for tHandle, pHande, nwItem in dummyItems: + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + + # Root item lookup + theTree._treeRoots.append("dummy") + assert theTree.findRoot(nwItemClass.WORLD) is None + assert theTree.findRoot(nwItemClass.NOVEL) == "a000000000001" + assert theTree.findRoot(nwItemClass.CHARACTER) == "a000000000004" + + # Check for root uniqueness + assert theTree.checkRootUnique(nwItemClass.CUSTOM) + assert theTree.checkRootUnique(nwItemClass.WORLD) + assert not theTree.checkRootUnique(nwItemClass.NOVEL) + assert not theTree.checkRootUnique(nwItemClass.CHARACTER) + + # Find root item of child item + assert theTree.getRootItem("b000000000001").itemHandle == "a000000000001" + assert theTree.getRootItem("c000000000001").itemHandle == "a000000000001" + assert theTree.getRootItem("c000000000002").itemHandle == "a000000000001" + assert theTree.getRootItem("dummy") is None + + # Get item path + assert theTree.getItemPath("dummy") == [] + assert theTree.getItemPath("c000000000001") == [ + "c000000000001", "b000000000001", "a000000000001" + ] + + # Break the folder parent handle + theTree["b000000000001"].itemParent = "dummy" + assert theTree.getItemPath("c000000000001") == [ + "c000000000001", "b000000000001" + ] + + theTree["b000000000001"].itemParent = "a000000000001" + assert theTree.getItemPath("c000000000001") == [ + "c000000000001", "b000000000001", "a000000000001" + ] + + # Change file layout + assert not theTree.setFileItemLayout("dummy", nwItemLayout.UNNUMBERED) + assert not theTree.setFileItemLayout("b000000000001", nwItemLayout.UNNUMBERED) + assert not theTree.setFileItemLayout("c000000000001", "stuff") + assert theTree.setFileItemLayout("c000000000001", nwItemLayout.UNNUMBERED) + assert theTree["c000000000001"].itemLayout == nwItemLayout.UNNUMBERED + +# END Test testCoreTree_Methods + +@pytest.mark.core +def testCoreTree_MakeHandles(dummyGUI, monkeypatch): + """Test generating item handles. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + theTree.setSeed(42) + + tHandle = theTree._makeHandle() + assert tHandle == "73475cb40a568" + + # Add the next in line to the project to foprce duplicate + theTree._projTree["44cb730c42048"] = None + tHandle = theTree._makeHandle() + assert tHandle == "71ee45a3c0db9" + + # Fix the time() function and force a handle collission + theTree.setSeed(None) + monkeypatch.setattr("nw.core.tree.time", lambda: 123.4) + + tHandle = theTree._makeHandle() + theTree._projTree[tHandle] = None + assert tHandle == "5f466d7afa48b" + + tHandle = theTree._makeHandle() + theTree._projTree[tHandle] = None + assert tHandle == "a79acf4c634a7" + + monkeypatch.undo() + +# END Test testCoreTree_MakeHandles + +@pytest.mark.core +def testCoreTree_Stats(dummyGUI, dummyItems): + """Test project stats methods. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + for tHandle, pHande, nwItem in dummyItems: + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + theTree._treeOrder.append("dummy") + + # Count Words + novelWords, noteWords = theTree.sumWords() + assert novelWords == 550 + assert noteWords == 400 + + # Count types + nRoot, nFolder, nFile = theTree.countTypes() + assert nRoot == 3 + assert nFolder == 1 + assert nFile == 3 + +# END Test testCoreTree_Stats + +@pytest.mark.core +def testCoreTree_Reorder(dummyGUI, dummyItems): + """Test changing tree order. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + aHandle = [] + for tHandle, pHande, nwItem in dummyItems: + aHandle.append(tHandle) + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + + bHandle = aHandle.copy() + bHandle[2], bHandle[3] = bHandle[3], bHandle[2] + assert aHandle != bHandle + + assert theTree.handles() == aHandle + theTree.setOrder(bHandle) + assert theTree.handles() == bHandle + + theTree.setOrder(bHandle + ["dummy"]) + assert theTree.handles() == bHandle + + theTree._treeOrder.append("dummy") + theTree.setOrder(bHandle) + assert theTree.handles() == bHandle + +# END Test testCoreTree_Reorder + +@pytest.mark.core +def testCoreTree_XMLPackUnpack(dummyGUI, dummyItems): + """Test changing tree order. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + for tHandle, pHande, nwItem in dummyItems: + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + + nwXML = etree.Element("novelWriterXML") + theTree.packXML(nwXML) + assert etree.tostring(nwXML, pretty_print=False, encoding="utf-8") == ( + b"" + b"" + b"" + b"NovelROOTNOVELNone" + b"True" + b"" + b"Act OneFOLDERNOVELNone" + b"True" + b"" + b"Chapter OneFILENOVELNone" + b"TrueUNNUMBERED300" + b"5020" + b"" + b"Scene OneFILENOVELNone" + b"TrueSCENE3000" + b"500200" + b"" + b"OuttakesROOTARCHIVENone" + b"False" + b"" + b"TrashTRASHTRASHNone" + b"False" + b"" + b"CharactersROOTCHARACTERNone" + b"True" + b"" + b"Jane DoeFILECHARACTERNone" + b"TrueNOTE2000" + b"400160" + b"" + ) + + theTree.clear() + assert len(theTree) == 0 + assert not theTree.unpackXML(nwXML) + assert theTree.unpackXML(nwXML[0]) + assert len(theTree) == len(dummyItems) + +# END Test testCoreTree_XMLPackUnpack + +@pytest.mark.core +def testCoreTree_ToCFile(dummyGUI, dummyItems, tmpDir, monkeypatch): + """Test writing the ToC.txt file. + """ + theProject = NWProject(dummyGUI) + theTree = NWTree(theProject) + + for tHandle, pHande, nwItem in dummyItems: + theTree.append(tHandle, pHande, nwItem) + + assert len(theTree) == len(dummyItems) + theTree._treeOrder.append("dummy") + + monkeypatch.setattr("os.path.isfile", lambda *args: True) + + theProject.projContent = "content" + theProject.projPath = None + assert not theTree.writeToCFile() + + theProject.projPath = tmpDir + assert theTree.writeToCFile() + + with open(os.path.join(tmpDir, nwFiles.TOC_TXT), mode="r", encoding="utf8") as inFile: + assert inFile.read() == ( + "\n" + "Table of Contents\n" + "=================\n" + "\n" + "File Name Class Layout Document Label\n" + "-------------------------------------------------------------\n" + "content/a000000000001.nwd NOVEL NO_LAYOUT Novel\n" + "content/b000000000001.nwd NOVEL NO_LAYOUT Act One\n" + "content/c000000000001.nwd NOVEL UNNUMBERED Chapter One\n" + "content/c000000000002.nwd NOVEL SCENE Scene One\n" + "content/a000000000002.nwd ARCHIVE NO_LAYOUT Outtakes\n" + "content/a000000000003.nwd TRASH NO_LAYOUT Trash\n" + "content/a000000000004.nwd CHARACTER NO_LAYOUT Characters\n" + "content/b000000000002.nwd CHARACTER NOTE Jane Doe\n" + ) + +# END Test testCoreTree_ToCFile From b4b359475dd3bf62e1d38d42802cf34290afbf0f Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 1 Dec 2020 09:41:56 +0100 Subject: [PATCH 15/52] Revert some changes to the NWTree.append class --- nw/core/tree.py | 8 +++---- tests/test_core_tree.py | 48 +++++++++++++++++------------------------ 2 files changed, 24 insertions(+), 32 deletions(-) diff --git a/nw/core/tree.py b/nw/core/tree.py index b76b6678..43678b73 100644 --- a/nw/core/tree.py +++ b/nw/core/tree.py @@ -86,8 +86,8 @@ class NWTree(): tHandle = self._makeHandle() if tHandle in self._projTree: - logger.warning("Duplicate handle %s detected, generating new" % tHandle) - tHandle = self._makeHandle() + logger.warning("Duplicate handle %s detected, skipping" % tHandle) + return False logger.verbose("Adding item %s with parent %s" % (str(tHandle), str(pHandle))) @@ -107,13 +107,13 @@ class NWTree(): self._trashRoot = tHandle else: logger.error("Only one trash folder allowed") - return + 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 diff --git a/tests/test_core_tree.py b/tests/test_core_tree.py index 1eff405d..f8c9e42b 100644 --- a/tests/test_core_tree.py +++ b/tests/test_core_tree.py @@ -107,7 +107,7 @@ def testCoreTree_BuildTree(dummyGUI, dummyItems): aHandles = [] for tHandle, pHande, nwItem in dummyItems: aHandles.append(tHandle) - theTree.append(tHandle, pHande, nwItem) + assert theTree.append(tHandle, pHande, nwItem) assert theTree._treeChanged @@ -136,7 +136,7 @@ def testCoreTree_BuildTree(dummyGUI, dummyItems): itemT.itemClass = nwItemClass.TRASH itemT.isExpanded = False - theTree.append("1234567890abc", None, itemT) + assert not theTree.append("1234567890abc", None, itemT) assert len(theTree) == len(dummyItems) # Generate handle automatically @@ -146,37 +146,26 @@ def testCoreTree_BuildTree(dummyGUI, dummyItems): itemT.itemClass = nwItemClass.NOVEL itemT.itemLayout = nwItemLayout.SCENE - theTree.append(None, None, itemT) + assert theTree.append(None, None, itemT) assert len(theTree) == len(dummyItems) + 1 theList = theTree.handles() assert theList[-1] == "73475cb40a568" - # Duplicate handle - itemT = NWItem(theProject) - itemT.itemName = "New File" - itemT.itemType = nwItemType.FILE - itemT.itemClass = nwItemClass.NOVEL - itemT.itemLayout = nwItemLayout.SCENE - - theTree.append("73475cb40a568", None, itemT) - assert len(theTree) == len(dummyItems) + 2 - - theList = theTree.handles() - assert theList[-1] == "44cb730c42048" - - # Delete the last two items - del theTree["dummy"] - assert len(theTree) == len(dummyItems) + 2 - - del theTree["44cb730c42048"] + # Try to add existing handle + assert not theTree.append("73475cb40a568", None, itemT) assert len(theTree) == len(dummyItems) + 1 - assert "44cb730c42048" not in theTree + # Delete a non-existing item + del theTree["dummy"] + assert len(theTree) == len(dummyItems) + 1 + + # Delete the last item del theTree["73475cb40a568"] assert len(theTree) == len(dummyItems) assert "73475cb40a568" not in theTree + # Delete the Novel, Archive and Trash folders del theTree["a000000000001"] assert len(theTree) == len(dummyItems) - 1 assert "a000000000001" not in theTree @@ -407,7 +396,15 @@ def testCoreTree_ToCFile(dummyGUI, dummyItems, tmpDir, monkeypatch): assert len(theTree) == len(dummyItems) theTree._treeOrder.append("dummy") - monkeypatch.setattr("os.path.isfile", lambda *args: True) + def dummyIsFile(fileName): + """Return True for items that are files in novelWriter and + should thus also be files in the project folder structure. + """ + dItem = theTree[fileName[8:21]] + assert dItem is not None + return dItem.itemType == nwItemType.FILE + + monkeypatch.setattr("os.path.isfile", dummyIsFile) theProject.projContent = "content" theProject.projPath = None @@ -424,13 +421,8 @@ def testCoreTree_ToCFile(dummyGUI, dummyItems, tmpDir, monkeypatch): "\n" "File Name Class Layout Document Label\n" "-------------------------------------------------------------\n" - "content/a000000000001.nwd NOVEL NO_LAYOUT Novel\n" - "content/b000000000001.nwd NOVEL NO_LAYOUT Act One\n" "content/c000000000001.nwd NOVEL UNNUMBERED Chapter One\n" "content/c000000000002.nwd NOVEL SCENE Scene One\n" - "content/a000000000002.nwd ARCHIVE NO_LAYOUT Outtakes\n" - "content/a000000000003.nwd TRASH NO_LAYOUT Trash\n" - "content/a000000000004.nwd CHARACTER NO_LAYOUT Characters\n" "content/b000000000002.nwd CHARACTER NOTE Jane Doe\n" ) From 2c0eaeb55727dac8b9adec289871cbc9fa8b8f92 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 1 Dec 2020 10:23:35 +0100 Subject: [PATCH 16/52] Fix old tests --- nw/core/item.py | 2 +- tests/conftest.py | 275 ++++++++++++++------------- tests/reference/proj/1_nwProject.nwx | 16 +- tests/reference/proj/2_nwProject.nwx | 24 +-- tests/reference/proj/3_nwProject.nwx | 20 +- tests/reference/proj/4_nwProject.nwx | 46 ++--- tests/reference/proj/5_nwProject.nwx | 28 +-- tests/test_config.py | 36 ++-- tests/test_core_tree.py | 16 +- tests/test_dialogs.py | 50 ++--- tests/test_error.py | 4 +- tests/test_gui.py | 58 +++--- tests/test_index.py | 30 +-- tests/test_project.py | 84 ++++---- 14 files changed, 345 insertions(+), 344 deletions(-) diff --git a/nw/core/item.py b/nw/core/item.py index ead16ab8..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 diff --git a/tests/conftest.py b/tests/conftest.py index 97aafd7d..863818eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,7 @@ import os from nwdummy import DummyMain -# from PyQt5.QtWidgets import QMessageBox +from PyQt5.QtWidgets import QMessageBox sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir))) @@ -33,28 +33,10 @@ def tmpDir(): os.mkdir(tempDir) return tempDir -# @pytest.fixture(scope="session") -# def nwRef(): -# """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 ## -# @pytest.fixture(scope="session") -# def tmpConf(nwTemp): -# """Create a temporary novelWriter configuration object. -# """ -# theConf = Config() -# theConf.initConfig(nwTemp, nwTemp) -# theConf.setLastPath("") -# return theConf - @pytest.fixture(scope="session") def tmpConf(tmpDir): """Create a temporary novelWriter configuration object. @@ -64,15 +46,6 @@ def tmpConf(tmpDir): 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 dummyGUI(tmpConf): """Create a dummy instance of novelWriter's main GUI class. @@ -81,131 +54,159 @@ def dummyGUI(tmpConf): theDummy.mainConf = tmpConf return theDummy +# =============================================================================================== # + +## +# Core Test Folders +## + +@pytest.fixture(scope="session") +def nwRef(): + """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 +## + +@pytest.fixture(scope="session") +def nwConf(nwRef, tmpDir): + """Temporary novelWriter configuration used for the dummy instance + of novelWriter's main GUI. + """ + theConf = Config() + theConf.initConfig(nwRef, tmpDir) + return theConf + ## # 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 nwTempProj(tmpDir): + """A temporary folder for project tests. + """ + projDir = os.path.join(tmpDir, "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 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(nwTemp): -# """A temporary folder for build tests. -# """ -# buildDir = os.path.join(nwTemp, "build") -# if not os.path.isdir(buildDir): -# os.mkdir(buildDir) -# return buildDir +@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 -# @pytest.fixture(scope="function") -# def nwFuncTemp(nwTemp): -# """A temporary folder for a single test function. -# """ -# funcDir = os.path.join(nwTemp, "ftemp") -# if os.path.isdir(funcDir): -# shutil.rmtree(funcDir) -# if not os.path.isdir(funcDir): -# os.mkdir(funcDir) -# yield funcDir -# if os.path.isdir(funcDir): -# shutil.rmtree(funcDir) -# return +@pytest.fixture(scope="function") +def nwFuncTemp(tmpDir): + """A temporary folder for a single test function. + """ + funcDir = os.path.join(tmpDir, "ftemp") + if os.path.isdir(funcDir): + shutil.rmtree(funcDir) + if not os.path.isdir(funcDir): + os.mkdir(funcDir) + yield funcDir + if os.path.isdir(funcDir): + shutil.rmtree(funcDir) + return ## # Temp Folders for Projects ## -# @pytest.fixture(scope="function") -# def nwMinimal(nwTemp): -# """A minimal novelWriter example project. -# """ -# testDir = os.path.dirname(__file__) -# minimalStore = os.path.join(testDir, "minimal") -# minimalDir = os.path.join(nwTemp, "minimal") -# if os.path.isdir(minimalDir): -# shutil.rmtree(minimalDir) -# shutil.copytree(minimalStore, minimalDir) -# cacheDir = os.path.join(minimalDir, "cache") -# if os.path.isdir(cacheDir): -# shutil.rmtree(cacheDir) -# metaDir = os.path.join(minimalDir, "meta") -# if os.path.isdir(metaDir): -# shutil.rmtree(metaDir) -# yield minimalDir -# if os.path.isdir(minimalDir): -# shutil.rmtree(minimalDir) -# return +@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(tmpDir, "minimal") + if os.path.isdir(minimalDir): + shutil.rmtree(minimalDir) + shutil.copytree(minimalStore, minimalDir) + cacheDir = os.path.join(minimalDir, "cache") + if os.path.isdir(cacheDir): + shutil.rmtree(cacheDir) + metaDir = os.path.join(minimalDir, "meta") + if os.path.isdir(metaDir): + shutil.rmtree(metaDir) + yield minimalDir + if os.path.isdir(minimalDir): + shutil.rmtree(minimalDir) + return -# @pytest.fixture(scope="function") -# def nwLipsum(nwTemp): -# """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") -# if os.path.isdir(lipsumDir): -# shutil.rmtree(lipsumDir) -# shutil.copytree(lipsumStore, lipsumDir) -# cacheDir = os.path.join(lipsumDir, "cache") -# if os.path.isdir(cacheDir): -# shutil.rmtree(cacheDir) -# metaDir = os.path.join(lipsumDir, "meta") -# if os.path.isdir(metaDir): -# shutil.rmtree(metaDir) -# yield lipsumDir -# if os.path.isdir(lipsumDir): -# shutil.rmtree(lipsumDir) -# return +@pytest.fixture(scope="function") +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(tmpDir, "lipsum") + if os.path.isdir(lipsumDir): + shutil.rmtree(lipsumDir) + shutil.copytree(lipsumStore, lipsumDir) + cacheDir = os.path.join(lipsumDir, "cache") + if os.path.isdir(cacheDir): + shutil.rmtree(cacheDir) + metaDir = os.path.join(lipsumDir, "meta") + if os.path.isdir(metaDir): + shutil.rmtree(metaDir) + yield lipsumDir + if os.path.isdir(lipsumDir): + shutil.rmtree(lipsumDir) + return -# @pytest.fixture(scope="function") -# def nwOldProj(nwTemp): -# """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") -# if os.path.isdir(oldProjDir): -# shutil.rmtree(oldProjDir) -# shutil.copytree(oldProjStore, oldProjDir) -# yield oldProjDir -# if os.path.isdir(oldProjDir): -# shutil.rmtree(oldProjDir) -# return +@pytest.fixture(scope="function") +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(tmpDir, "oldproj") + if os.path.isdir(oldProjDir): + shutil.rmtree(oldProjDir) + shutil.copytree(oldProjStore, oldProjDir) + yield oldProjDir + if os.path.isdir(oldProjDir): + shutil.rmtree(oldProjDir) + return ## # Monkey Patch Dialogs ## -# @pytest.fixture(scope="function") -# def yesToAll(monkeypatch): -# """Make the message boxes/questions always say yes. -# """ -# monkeypatch.setattr( -# QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes -# ) -# monkeypatch.setattr( -# QMessageBox, "information", lambda *args, **kwargs: QMessageBox.Yes -# ) -# monkeypatch.setattr( -# QMessageBox, "warning", lambda *args, **kwargs: QMessageBox.Yes -# ) -# monkeypatch.setattr( -# QMessageBox, "critical", lambda *args, **kwargs: QMessageBox.Yes -# ) -# return +@pytest.fixture(scope="function") +def yesToAll(monkeypatch): + """Make the message boxes/questions always say yes. + """ + monkeypatch.setattr( + QMessageBox, "question", lambda *args, **kwargs: QMessageBox.Yes + ) + monkeypatch.setattr( + QMessageBox, "information", lambda *args, **kwargs: QMessageBox.Yes + ) + monkeypatch.setattr( + QMessageBox, "warning", lambda *args, **kwargs: QMessageBox.Yes + ) + monkeypatch.setattr( + QMessageBox, "critical", lambda *args, **kwargs: QMessageBox.Yes + ) + return diff --git a/tests/reference/proj/1_nwProject.nwx b/tests/reference/proj/1_nwProject.nwx index 61ece12f..d8b774bb 100644 --- a/tests/reference/proj/1_nwProject.nwx +++ b/tests/reference/proj/1_nwProject.nwx @@ -39,35 +39,35 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + World ROOT WORLD New False - + Title Page FILE NOVEL @@ -79,14 +79,14 @@ 0 0 - + New Chapter FOLDER NOVEL New False - + New Chapter FILE NOVEL @@ -98,7 +98,7 @@ 0 0 - + New Scene FILE NOVEL diff --git a/tests/reference/proj/2_nwProject.nwx b/tests/reference/proj/2_nwProject.nwx index cc7b54ea..9ea0617f 100644 --- a/tests/reference/proj/2_nwProject.nwx +++ b/tests/reference/proj/2_nwProject.nwx @@ -39,35 +39,35 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + World ROOT WORLD New False - + Title Page FILE NOVEL @@ -79,14 +79,14 @@ 0 0 - + New Chapter FOLDER NOVEL New False - + New Chapter FILE NOVEL @@ -98,7 +98,7 @@ 0 0 - + New Scene FILE NOVEL @@ -110,28 +110,28 @@ 0 0 - + Timeline ROOT TIMELINE New False - + Object ROOT OBJECT New False - + Custom1 ROOT CUSTOM New False - + Custom2 ROOT CUSTOM diff --git a/tests/reference/proj/3_nwProject.nwx b/tests/reference/proj/3_nwProject.nwx index c9e450d6..66488434 100644 --- a/tests/reference/proj/3_nwProject.nwx +++ b/tests/reference/proj/3_nwProject.nwx @@ -39,35 +39,35 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + World ROOT WORLD New False - + Title Page FILE NOVEL @@ -79,14 +79,14 @@ 0 0 - + New Chapter FOLDER NOVEL New False - + New Chapter FILE NOVEL @@ -98,7 +98,7 @@ 0 0 - + New Scene FILE NOVEL @@ -110,7 +110,7 @@ 0 0 - + Hello FILE NOVEL @@ -122,7 +122,7 @@ 0 0 - + Jane FILE CHARACTER diff --git a/tests/reference/proj/4_nwProject.nwx b/tests/reference/proj/4_nwProject.nwx index 4c9c30eb..fc7e7ab2 100644 --- a/tests/reference/proj/4_nwProject.nwx +++ b/tests/reference/proj/4_nwProject.nwx @@ -41,56 +41,56 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + Locations ROOT WORLD New False - + Timeline ROOT TIMELINE New False - + Objects ROOT OBJECT New False - + Entity ROOT ENTITY New False - + Title Page FILE NOVEL @@ -102,14 +102,14 @@ 0 0 - + Chapter 1 FOLDER NOVEL New False - + Chapter 1 FILE NOVEL @@ -121,7 +121,7 @@ 0 0 - + Scene 1.1 FILE NOVEL @@ -133,7 +133,7 @@ 0 0 - + Scene 1.2 FILE NOVEL @@ -145,7 +145,7 @@ 0 0 - + Scene 1.3 FILE NOVEL @@ -157,14 +157,14 @@ 0 0 - + Chapter 2 FOLDER NOVEL New False - + Chapter 2 FILE NOVEL @@ -176,7 +176,7 @@ 0 0 - + Scene 2.1 FILE NOVEL @@ -188,7 +188,7 @@ 0 0 - + Scene 2.2 FILE NOVEL @@ -200,7 +200,7 @@ 0 0 - + Scene 2.3 FILE NOVEL @@ -212,14 +212,14 @@ 0 0 - + Chapter 3 FOLDER NOVEL New False - + Chapter 3 FILE NOVEL @@ -231,7 +231,7 @@ 0 0 - + Scene 3.1 FILE NOVEL @@ -243,7 +243,7 @@ 0 0 - + Scene 3.2 FILE NOVEL @@ -255,7 +255,7 @@ 0 0 - + Scene 3.3 FILE NOVEL diff --git a/tests/reference/proj/5_nwProject.nwx b/tests/reference/proj/5_nwProject.nwx index 9935736e..11d007aa 100644 --- a/tests/reference/proj/5_nwProject.nwx +++ b/tests/reference/proj/5_nwProject.nwx @@ -41,56 +41,56 @@ - + Novel ROOT NOVEL New False - + Plot ROOT PLOT New False - + Characters ROOT CHARACTER New False - + Locations ROOT WORLD New False - + Timeline ROOT TIMELINE New False - + Objects ROOT OBJECT New False - + Entity ROOT ENTITY New False - + Title Page FILE NOVEL @@ -102,7 +102,7 @@ 0 0 - + Scene 1 FILE NOVEL @@ -114,7 +114,7 @@ 0 0 - + Scene 2 FILE NOVEL @@ -126,7 +126,7 @@ 0 0 - + Scene 3 FILE NOVEL @@ -138,7 +138,7 @@ 0 0 - + Scene 4 FILE NOVEL @@ -150,7 +150,7 @@ 0 0 - + Scene 5 FILE NOVEL @@ -162,7 +162,7 @@ 0 0 - + Scene 6 FILE NOVEL diff --git a/tests/test_config.py b/tests/test_config.py index 5d72cdfd..ad83ddd5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,11 +8,11 @@ import os from nwtools import cmpFiles @pytest.mark.core -def testConfigCore(tmpConf, nwTemp, nwRef): +def testConfigCore(tmpConf, tmpDir, nwRef): refConf = os.path.join(nwRef, "novelwriter.conf") testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - assert tmpConf.confPath == nwTemp + assert tmpConf.confPath == tmpDir assert tmpConf.saveConfig() assert cmpFiles(testConf, refConf, [2, 9]) assert not tmpConf.confChanged @@ -21,29 +21,29 @@ def testConfigCore(tmpConf, nwTemp, nwRef): assert not tmpConf.confChanged @pytest.mark.core -def testConfigSetConfPath(tmpConf, nwTemp): +def testConfigSetConfPath(tmpConf, tmpDir): 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.setConfPath(os.path.join(tmpDir, "novelwriter.conf")) + assert tmpConf.confPath == tmpDir assert tmpConf.confFile == "novelwriter.conf" assert not tmpConf.confChanged @pytest.mark.core -def testConfigSetDataPath(tmpConf, nwTemp): +def testConfigSetDataPath(tmpConf, tmpDir): assert tmpConf.setDataPath(None) assert not tmpConf.setDataPath(os.path.join("somewhere", "over", "the", "rainbow")) - assert tmpConf.setDataPath(nwTemp) - assert tmpConf.dataPath == nwTemp + assert tmpConf.setDataPath(tmpDir) + assert tmpConf.dataPath == tmpDir assert not tmpConf.confChanged @pytest.mark.core -def testConfigSetWinSize(tmpConf, nwTemp, nwRef): +def testConfigSetWinSize(tmpConf, tmpDir, 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.confPath == tmpDir assert tmpConf.setWinSize(1205, 655) assert not tmpConf.confChanged assert tmpConf.setWinSize(70, 70) @@ -55,11 +55,11 @@ def testConfigSetWinSize(tmpConf, nwTemp, nwRef): assert not tmpConf.confChanged @pytest.mark.core -def testConfigSetTreeColWidths(tmpConf, nwTemp, nwRef): +def testConfigSetTreeColWidths(tmpConf, tmpDir, nwRef): refConf = os.path.join(nwRef, "novelwriter.conf") testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - assert tmpConf.confPath == nwTemp + assert tmpConf.confPath == tmpDir tmpConf.guiScale = 1.0 assert tmpConf.setTreeColWidths([10, 20, 25]) @@ -77,11 +77,11 @@ def testConfigSetTreeColWidths(tmpConf, nwTemp, nwRef): assert not tmpConf.confChanged @pytest.mark.core -def testConfigSetPanePos(tmpConf, nwTemp, nwRef): +def testConfigSetPanePos(tmpConf, tmpDir, nwRef): refConf = os.path.join(nwRef, "novelwriter.conf") testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - assert tmpConf.confPath == nwTemp + assert tmpConf.confPath == tmpDir tmpConf.guiScale = 2.0 assert tmpConf.setMainPanePos([200, 700]) @@ -113,11 +113,11 @@ def testConfigSetPanePos(tmpConf, nwTemp, nwRef): assert not tmpConf.confChanged @pytest.mark.core -def testConfigFlags(tmpConf, nwTemp, nwRef): +def testConfigFlags(tmpConf, tmpDir, nwRef): refConf = os.path.join(nwRef, "novelwriter.conf") testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - assert tmpConf.confPath == nwTemp + assert tmpConf.confPath == tmpDir assert not tmpConf.setShowRefPanel(False) assert tmpConf.setShowRefPanel(True) @@ -137,8 +137,8 @@ def testConfigFlags(tmpConf, nwTemp, nwRef): assert not tmpConf.confChanged @pytest.mark.core -def testTextSizes(tmpConf, nwTemp, nwRef): - assert tmpConf.confPath == nwTemp +def testTextSizes(tmpConf, tmpDir, nwRef): + assert tmpConf.confPath == tmpDir tmpConf.guiScale = 2.0 assert tmpConf.getTextWidth() == 1200 diff --git a/tests/test_core_tree.py b/tests/test_core_tree.py index f8c9e42b..c3b68964 100644 --- a/tests/test_core_tree.py +++ b/tests/test_core_tree.py @@ -345,30 +345,30 @@ def testCoreTree_XMLPackUnpack(dummyGUI, dummyItems): assert etree.tostring(nwXML, pretty_print=False, encoding="utf-8") == ( b"" b"" - b"" + b"" b"NovelROOTNOVELNone" b"True" - b"" + b"" b"Act OneFOLDERNOVELNone" b"True" - b"" + b"" b"Chapter OneFILENOVELNone" b"TrueUNNUMBERED300" b"5020" - b"" + b"" b"Scene OneFILENOVELNone" b"TrueSCENE3000" b"500200" - b"" + b"" b"OuttakesROOTARCHIVENone" b"False" - b"" + b"" b"TrashTRASHTRASHNone" b"False" - b"" + b"" b"CharactersROOTCHARACTERNone" b"True" - b"" + b"" b"Jane DoeFILECHARACTERNone" b"TrueNOTE2000" b"400160" diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index 4c1f1885..5e9a319f 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -30,8 +30,8 @@ typeDelay = 1 stepDelay = 20 @pytest.mark.gui -def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) +def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwRef, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -145,8 +145,8 @@ def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwR nwGUI.closeMain() @pytest.mark.gui -def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, nwRef, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) +def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, nwRef, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -218,8 +218,8 @@ def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, nwRef, n nwGUI.closeMain() @pytest.mark.gui -def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) +def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -357,8 +357,8 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTemp): nwGUI.closeMain() @pytest.mark.gui -def testAboutBox(qtbot, monkeypatch, nwFuncTemp, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp]) +def testAboutBox(qtbot, monkeypatch, nwFuncTemp, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -397,9 +397,9 @@ def testAboutBox(qtbot, monkeypatch, nwFuncTemp, nwTemp): nwGUI.closeMain() @pytest.mark.gui -def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, nwTemp): +def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -589,9 +589,9 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, nwTemp): nwGUI.closeMain() @pytest.mark.gui -def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef, nwTemp): +def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -740,7 +740,7 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef nwGUI.closeMain() @pytest.mark.gui -def testNewProjectWizard(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): +def testNewProjectWizard(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir): if sys.platform.startswith("darwin"): # Disable for macOS because the test segfaults on QWizard.show() @@ -752,7 +752,7 @@ def testNewProjectWizard(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): ProjWizardCustomPage, ProjWizardFinalPage ) - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -937,8 +937,8 @@ def testNewProjectWizard(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): nwGUI.close() @pytest.mark.gui -def testLoadProject(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp]) +def testLoadProject(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1017,8 +1017,8 @@ def testLoadProject(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): nwGUI.close() @pytest.mark.gui -def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp, nwRef, tmpConf): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp]) +def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir, nwRef, tmpConf): + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1205,7 +1205,7 @@ def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp, nwRef, tmpC refConf = os.path.join(nwRef, "novelwriter_prefs.conf") projConf = os.path.join(nwGUI.mainConf.confPath, "novelwriter.conf") - testConf = os.path.join(nwTemp, "novelwriter_prefs.conf") + testConf = os.path.join(tmpDir, "novelwriter_prefs.conf") copyfile(projConf, testConf) ignoreLines = [ 2, # Timestamp @@ -1216,8 +1216,8 @@ def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp, nwRef, tmpC assert cmpFiles(testConf, refConf, ignoreLines) @pytest.mark.gui -def testQuotesDialog(qtbot, yesToAll, nwMinimal, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) +def testQuotesDialog(qtbot, yesToAll, nwMinimal, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1246,15 +1246,15 @@ def testQuotesDialog(qtbot, yesToAll, nwMinimal, nwTemp): nwGUI.close() @pytest.mark.gui -def testDialogsOpenClose(qtbot, monkeypatch, yesToAll, nwMinimal, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) +def testDialogsOpenClose(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) qtbot.wait(stepDelay) - monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args, **kwargs: nwTemp) - assert nwGUI.selectProjectPath() == nwTemp + monkeypatch.setattr(QFileDialog, "getExistingDirectory", lambda *args, **kwargs: tmpDir) + assert nwGUI.selectProjectPath() == tmpDir # qtbot.stopForInteraction() nwGUI.closeMain() diff --git a/tests/test_error.py b/tests/test_error.py index 179efa68..7ad07269 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -10,9 +10,9 @@ from PyQt5.QtWidgets import qApp from nw.error import NWErrorMessage, exceptionHandler @pytest.mark.error -def testErrorDialog(qtbot, nwFuncTemp, nwTemp): +def testErrorDialog(qtbot, nwFuncTemp, tmpDir): qApp.closeAllWindows() - nwGUI = nw.main(["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) diff --git a/tests/test_gui.py b/tests/test_gui.py index fcf96c06..2b10b5de 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -27,11 +27,11 @@ typeDelay = 1 stepDelay = 20 @pytest.mark.gui -def testLaunch(qtbot, monkeypatch, nwFuncTemp, nwTemp): +def testLaunch(qtbot, monkeypatch, nwFuncTemp, tmpDir): # Defaults nwGUI = nw.main( - ["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp, "--style=Fusion"] + ["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir, "--style=Fusion"] ) assert nw.logger.getEffectiveLevel() == logging.WARNING nwGUI.closeMain() @@ -39,21 +39,21 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, nwTemp): # Log Levels nwGUI = nw.main( - ["--testmode", "--info", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] + ["--testmode", "--info", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] ) assert nw.logger.getEffectiveLevel() == logging.INFO nwGUI.closeMain() nwGUI.close() nwGUI = nw.main( - ["--testmode", "--debug", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] + ["--testmode", "--debug", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] ) assert nw.logger.getEffectiveLevel() == logging.DEBUG nwGUI.closeMain() nwGUI.close() nwGUI = nw.main( - ["--testmode", "--verbose", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] + ["--testmode", "--verbose", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] ) assert nw.logger.getEffectiveLevel() == 5 nwGUI.closeMain() @@ -62,7 +62,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, nwTemp): # Help and Version with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--help", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] + ["--testmode", "--help", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] ) nwGUI.closeMain() nwGUI.close() @@ -70,7 +70,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, nwTemp): with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--version", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] + ["--testmode", "--version", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] ) nwGUI.closeMain() nwGUI.close() @@ -79,7 +79,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, nwTemp): # Invalid options with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--invalid", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] + ["--testmode", "--invalid", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] ) nwGUI.closeMain() nwGUI.close() @@ -92,7 +92,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, nwTemp): monkeypatch.setattr("nw.CONFIG.verPyQtValue", 50000) with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % nwTemp] + ["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] ) nwGUI.closeMain() nwGUI.close() @@ -103,9 +103,9 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, nwTemp): monkeypatch.undo() @pytest.mark.gui -def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): +def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -414,9 +414,9 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, nwTemp): nwGUI.close() @pytest.mark.gui -def testDocViewer(qtbot, yesToAll, nwLipsum, nwTemp): +def testDocViewer(qtbot, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -566,9 +566,9 @@ def testDocViewer(qtbot, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testProjectTree(qtbot, yesToAll, nwMinimal, nwTemp): +def testProjectTree(qtbot, yesToAll, nwMinimal, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -690,9 +690,9 @@ def testProjectTree(qtbot, yesToAll, nwMinimal, nwTemp): nwGUI.close() @pytest.mark.gui -def testEditFormatMenu(qtbot, yesToAll, nwLipsum, nwTemp): +def testEditFormatMenu(qtbot, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -927,9 +927,9 @@ def testEditFormatMenu(qtbot, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testContextMenu(qtbot, yesToAll, nwLipsum, nwTemp): +def testContextMenu(qtbot, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1013,8 +1013,8 @@ def testContextMenu(qtbot, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, nwTemp): - nwGUI = nw.main(["--testmode", "--config=%s" % nwTemp, "--data=%s" % nwTemp]) +def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1162,7 +1162,7 @@ def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, nwTemp): assert not nwGUI.importDocument() # Then a valid path, but bot a file that exists - theFile = os.path.join(nwTemp, "import.txt") + theFile = os.path.join(tmpDir, "import.txt") monkeypatch.setattr(QFileDialog, "getOpenFileName", lambda *args, **kwards: (theFile, "")) assert not nwGUI.importDocument() @@ -1212,9 +1212,9 @@ def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, nwTemp): nwGUI.close() @pytest.mark.gui -def testTextSearch(qtbot, monkeypatch, yesToAll, nwLipsum, nwTemp): +def testTextSearch(qtbot, monkeypatch, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1392,9 +1392,9 @@ def testTextSearch(qtbot, monkeypatch, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testOutline(qtbot, yesToAll, nwLipsum, nwTemp): +def testOutline(qtbot, yesToAll, nwLipsum, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % nwTemp]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1453,9 +1453,9 @@ def testOutline(qtbot, yesToAll, nwLipsum, nwTemp): nwGUI.close() @pytest.mark.gui -def testThemes(qtbot, yesToAll, nwMinimal, nwTemp): +def testThemes(qtbot, yesToAll, nwMinimal, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) @@ -1478,7 +1478,7 @@ def testThemes(qtbot, yesToAll, nwMinimal, nwTemp): # Re-open assert nw.CONFIG.confPath == nwMinimal - nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % nwTemp, nwMinimal]) + nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir, nwMinimal]) assert nwGUI.mainConf.confPath == nwMinimal qtbot.addWidget(nwGUI) nwGUI.show() diff --git a/tests/test_index.py b/tests/test_index.py index 9fabae5b..dd5add5c 100644 --- a/tests/test_index.py +++ b/tests/test_index.py @@ -15,7 +15,7 @@ from nw.core.index import NWIndex from nw.constants import nwItemClass, nwItemLayout @pytest.mark.project -def testIndexBuildCheck(monkeypatch, nwLipsum, nwDummy, nwTempProj, nwRef): +def testIndexBuildCheck(monkeypatch, nwLipsum, dummyGUI, nwTempProj, nwRef): """Test core functionality of scaning, saving, loading and checking the index cache file. """ @@ -23,13 +23,13 @@ def testIndexBuildCheck(monkeypatch, nwLipsum, nwDummy, nwTempProj, nwRef): testFile = os.path.join(nwTempProj, "1_tagsIndex.json") refFile = os.path.join(nwRef, "proj", "1_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 @@ -139,14 +139,14 @@ def testIndexBuildCheck(monkeypatch, nwLipsum, nwDummy, nwTempProj, nwRef): assert cmpFiles(testFile, refFile) @pytest.mark.project -def testIndexScanThis(nwMinimal, nwDummy): +def testIndexScanThis(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 @@ -188,14 +188,14 @@ def testIndexScanThis(nwMinimal, nwDummy): assert theProject.closeProject() @pytest.mark.project -def testIndexCheckThese(nwMinimal, nwDummy): +def testIndexCheckThese(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] @@ -224,14 +224,14 @@ def testIndexCheckThese(nwMinimal, nwDummy): assert theProject.closeProject() @pytest.mark.project -def testIndexScanText(nwMinimal, nwDummy): +def testIndexScanText(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) # Some items for fail to scan tests dHandle = theProject.newFolder("Folder", nwItemClass.NOVEL, "a508bb932959c") @@ -399,14 +399,14 @@ def testIndexScanText(nwMinimal, nwDummy): assert theProject.closeProject() @pytest.mark.project -def testIndexExtractData(nwMinimal, nwDummy): +def testIndexExtractData(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") diff --git a/tests/test_project.py b/tests/test_project.py index 5ed9d261..b8a49aa9 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -16,14 +16,14 @@ from nw.core.spellcheck import NWSpellEnchant, NWSpellSimple from nw.constants import nwConst, nwItemClass, nwItemType, nwItemLayout, nwFiles @pytest.mark.project -def testProjectNewOpenSave(nwFuncTemp, nwTempProj, nwRef, nwTemp, nwDummy): +def testProjectNewOpenSave(nwFuncTemp, nwTempProj, nwRef, tmpDir, dummyGUI): """Test that a basic project can be created, and opened and saved. """ projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempProj, "1_nwProject.nwx") refFile = os.path.join(nwRef, "proj", "1_nwProject.nwx") - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) # Setting no data should fail @@ -62,14 +62,14 @@ def testProjectNewOpenSave(nwFuncTemp, nwTempProj, nwRef, nwTemp, nwDummy): assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) @pytest.mark.project -def testProjectNewRoot(nwFuncTemp, nwTempProj, nwRef, nwDummy): +def testProjectNewRoot(nwFuncTemp, nwTempProj, nwRef, dummyGUI): """Check that new root folders can be added to the project. """ projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempProj, "2_nwProject.nwx") refFile = os.path.join(nwRef, "proj", "2_nwProject.nwx") - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.newProject({"projPath": nwFuncTemp}) @@ -96,14 +96,14 @@ def testProjectNewRoot(nwFuncTemp, nwTempProj, nwRef, nwDummy): assert not theProject.projChanged @pytest.mark.project -def testProjectNewFile(nwFuncTemp, nwTempProj, nwRef, nwDummy): +def testProjectNewFile(nwFuncTemp, nwTempProj, nwRef, dummyGUI): """Check that new files can be added to the project. """ projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempProj, "3_nwProject.nwx") refFile = os.path.join(nwRef, "proj", "3_nwProject.nwx") - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.newProject({"projPath": nwFuncTemp}) @@ -123,7 +123,7 @@ def testProjectNewFile(nwFuncTemp, nwTempProj, nwRef, nwDummy): assert not theProject.projChanged @pytest.mark.project -def testProjectNewCustomA(nwFuncTemp, nwTempProj, nwRef, nwDummy): +def testProjectNewCustomA(nwFuncTemp, nwTempProj, nwRef, dummyGUI): """Create a new project from a project wizard dictionary. Custom type with chapters and scenes. """ @@ -151,7 +151,7 @@ def testProjectNewCustomA(nwFuncTemp, nwTempProj, nwRef, nwDummy): "numScenes": 3, "chFolders": True, } - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.newProject(projData) @@ -162,7 +162,7 @@ def testProjectNewCustomA(nwFuncTemp, nwTempProj, nwRef, nwDummy): assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) @pytest.mark.project -def testProjectNewCustomB(nwFuncTemp, nwTempProj, nwRef, nwDummy): +def testProjectNewCustomB(nwFuncTemp, nwTempProj, nwRef, dummyGUI): """Create a new project from a project wizard dictionary. Custom type without chapters, but with scenes. """ @@ -190,7 +190,7 @@ def testProjectNewCustomB(nwFuncTemp, nwTempProj, nwRef, nwDummy): "numScenes": 6, "chFolders": True, } - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.newProject(projData) @@ -201,7 +201,7 @@ def testProjectNewCustomB(nwFuncTemp, nwTempProj, nwRef, nwDummy): assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) @pytest.mark.project -def testProjectNewSampleA(nwFuncTemp, nwConf, nwDummy, nwTemp): +def testProjectNewSampleA(nwFuncTemp, nwConf, dummyGUI, tmpDir): """Check that we can create a new project can be created from the provided sample project via a zip file. """ @@ -214,7 +214,7 @@ def testProjectNewSampleA(nwFuncTemp, nwConf, nwDummy, nwTemp): "popMinimal": False, "popCustom": False, } - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) theProject.mainConf = nwConf @@ -223,8 +223,8 @@ def testProjectNewSampleA(nwFuncTemp, nwConf, nwDummy, nwTemp): # Force the lookup path for assets to our temp folder srcSample = os.path.abspath(os.path.join(nwConf.appRoot, "sample")) - dstSample = os.path.join(nwTemp, "sample.zip") - nwConf.assetPath = nwTemp + dstSample = os.path.join(tmpDir, "sample.zip") + nwConf.assetPath = tmpDir # Create and open a defective zip file with open(dstSample, mode="w+") as outFile: @@ -248,7 +248,7 @@ def testProjectNewSampleA(nwFuncTemp, nwConf, nwDummy, nwTemp): os.unlink(dstSample) @pytest.mark.project -def testProjectNewSampleB(monkeypatch, nwFuncTemp, nwConf, nwDummy, nwTemp): +def testProjectNewSampleB(monkeypatch, nwFuncTemp, nwConf, dummyGUI, tmpDir): """Check that we can create a new project can be created from the provided sample project folder. """ @@ -261,12 +261,12 @@ def testProjectNewSampleB(monkeypatch, nwFuncTemp, nwConf, nwDummy, nwTemp): "popMinimal": False, "popCustom": False, } - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) theProject.mainConf = nwConf # Make sure we do not pick up the nw/assets/sample.zip file - nwConf.assetPath = nwTemp + nwConf.assetPath = tmpDir # Set a fake project file name monkeypatch.setattr(nwFiles, "PROJ_FILE", "nothing.nwx") @@ -280,14 +280,14 @@ def testProjectNewSampleB(monkeypatch, nwFuncTemp, nwConf, nwDummy, nwTemp): assert theProject.closeProject() # Misdirect the appRoot path so neither is possible - nwConf.appRoot = nwTemp + nwConf.appRoot = tmpDir assert not theProject.newProject(projData) @pytest.mark.project -def testProjectMethods(monkeypatch, nwMinimal, nwDummy): +def testProjectMethods(monkeypatch, nwMinimal, dummyGUI): """Test other project class methods and functions. """ - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.openProject(nwMinimal) assert theProject.projPath == nwMinimal @@ -326,14 +326,14 @@ def testProjectMethods(monkeypatch, nwMinimal, nwDummy): assert theProject.bookAuthors == ["Jane Doe", "John Doh"] @pytest.mark.project -def testDocMeta(nwDummy, nwLipsum): +def testDocMeta(dummyGUI, nwLipsum): """Check that the document meta data string is parsed correctly. """ - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) assert theProject.openProject(nwLipsum) - aDoc = NWDoc(theProject, nwDummy) + aDoc = NWDoc(theProject, dummyGUI) assert aDoc.openDocument("47666c91c7ccf") theName, theParent, theClass, theLayout = aDoc.getMeta() @@ -350,8 +350,8 @@ def testDocMeta(nwDummy, nwLipsum): assert theLayout is None @pytest.mark.project -def testSpellEnchant(nwTemp, nwConf): - wList = os.path.join(nwTemp, "wordlist.txt") +def testSpellEnchant(tmpDir, nwConf): + wList = os.path.join(tmpDir, "wordlist.txt") with open(wList, mode="w") as wFile: wFile.write("a_word\nb_word\nc_word\n") @@ -379,8 +379,8 @@ def testSpellEnchant(nwTemp, nwConf): assert aName != "" @pytest.mark.project -def testSpellSimple(nwTemp, nwConf): - wList = os.path.join(nwTemp, "wordlist.txt") +def testSpellSimple(tmpDir, nwConf): + wList = os.path.join(tmpDir, "wordlist.txt") with open(wList, mode="w") as wFile: wFile.write("a_word\nb_word\nc_word\n") @@ -408,12 +408,12 @@ def testSpellSimple(nwTemp, nwConf): assert aName == nwConst.SP_INTERNAL @pytest.mark.project -def testProjectOptions(nwDummy, nwLipsum): +def testProjectOptions(dummyGUI, nwLipsum): """Test the class that holds all the GUI state user options that are tied to the current open project. Non-project related GUI options are handled by the Config class. """ - theProject = NWProject(nwDummy) + theProject = NWProject(dummyGUI) assert theProject.projMeta is None theOpts = theProject.optState @@ -471,13 +471,13 @@ def testProjectOptions(nwDummy, nwLipsum): assert theOpts.getFloat("GuiWritingStats", "winWidth", False) is False @pytest.mark.project -def testProjectOrphanedFiles(nwDummy, nwLipsum): +def testProjectOrphanedFiles(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(nwDummy) + theProject = NWProject(dummyGUI) assert theProject.openProject(nwLipsum) assert theProject.projTree["636b6aa9b697b"] is None assert theProject.closeProject() @@ -540,13 +540,13 @@ def testProjectOrphanedFiles(nwDummy, nwLipsum): assert theProject.closeProject() @pytest.mark.project -def testProjectOldFormat(nwDummy, nwOldProj): +def testProjectOldFormat(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(nwDummy) + theProject = NWProject(dummyGUI) theProject.mainConf.showGUI = False # Create dummy files for known legacy files @@ -630,13 +630,13 @@ def testProjectOldFormat(nwDummy, nwOldProj): assert os.path.isfile(os.path.join(nwOldProj, "ToC.txt")) @pytest.mark.project -def testProjectBackup(nwDummy, nwMinimal, nwTemp): +def testProjectBackup(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(nwDummy) + theProject = NWProject(dummyGUI) assert theProject.openProject(nwMinimal) # Test faulty settings @@ -645,12 +645,12 @@ def testProjectBackup(nwDummy, nwMinimal, nwTemp): assert not theProject.zipIt(doNotify=False) # Missing project name - theProject.mainConf.backupPath = nwTemp + theProject.mainConf.backupPath = tmpDir theProject.projName = "" assert not theProject.zipIt(doNotify=False) # Non-existent folder - theProject.mainConf.backupPath = os.path.join(nwTemp, "nonexistent") + theProject.mainConf.backupPath = os.path.join(tmpDir, "nonexistent") theProject.projName = "Test Minimal" assert not theProject.zipIt(doNotify=False) @@ -659,10 +659,10 @@ def testProjectBackup(nwDummy, nwMinimal, nwTemp): assert not theProject.zipIt(doNotify=False) # Test correct settings - theProject.mainConf.backupPath = nwTemp + theProject.mainConf.backupPath = tmpDir assert theProject.zipIt(doNotify=False) - theFiles = os.listdir(os.path.join(nwTemp, "Test Minimal")) + theFiles = os.listdir(os.path.join(tmpDir, "Test Minimal")) assert len(theFiles) == 1 theZip = theFiles[0] @@ -670,10 +670,10 @@ def testProjectBackup(nwDummy, nwMinimal, nwTemp): assert theZip[-4:] == ".zip" # Extract the archive - with ZipFile(os.path.join(nwTemp, "Test Minimal", theZip), "r") as inZip: - inZip.extractall(os.path.join(nwTemp, "extract")) + 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(nwTemp, "extract", "nwProject.nwx") + os.path.join(nwMinimal, "nwProject.nwx"), os.path.join(tmpDir, "extract", "nwProject.nwx") ) From deededb0d9f730385159fb187b5a3a81f4aecd87 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 1 Dec 2020 10:49:06 +0100 Subject: [PATCH 17/52] Updated NWIndex tests --- ...json => coreIndex_LoadSave_tagsIndex.json} | 0 tests/{test_index.py => test_core_index.py} | 95 +++++++++++-------- tests/test_core_tree.py | 4 +- 3 files changed, 58 insertions(+), 41 deletions(-) rename tests/reference/{proj/1_tagsIndex.json => coreIndex_LoadSave_tagsIndex.json} (100%) rename tests/{test_index.py => test_core_index.py} (87%) 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/test_index.py b/tests/test_core_index.py similarity index 87% rename from tests/test_index.py rename to tests/test_core_index.py index dd5add5c..a3bafa2f 100644 --- a/tests/test_index.py +++ b/tests/test_core_index.py @@ -14,14 +14,14 @@ 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, dummyGUI, nwTempProj, nwRef): +@pytest.mark.core +def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, nwTempProj, nwRef): """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(nwTempProj, "coreIndex_LoadSave_tagsIndex.json") + compFile = os.path.join(nwRef, "coreIndex_LoadSave_tagsIndex.json") theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) @@ -136,10 +136,12 @@ def testIndexBuildCheck(monkeypatch, nwLipsum, dummyGUI, nwTempProj, nwRef): assert theProject.closeProject() copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile) + assert cmpFiles(testFile, compFile) -@pytest.mark.project -def testIndexScanThis(nwMinimal, dummyGUI): +# END Test testCoreIndex_LoadSave + +@pytest.mark.core +def testCoreIndex_ScanThis(nwMinimal, dummyGUI): """Test the tag scanner function scanThis. """ theProject = NWProject(dummyGUI) @@ -162,33 +164,35 @@ def testIndexScanThis(nwMinimal, dummyGUI): 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, dummyGUI): +# END Test testCoreIndex_ScanThis + +@pytest.mark.core +def testCoreIndex_CheckThese(nwMinimal, dummyGUI): """Test the tag checker function checkThese. """ theProject = NWProject(dummyGUI) @@ -209,23 +213,26 @@ def testIndexCheckThese(nwMinimal, dummyGUI): "# 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, dummyGUI): - """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(dummyGUI) theProject.projTree.setSeed(42) @@ -392,14 +399,16 @@ def testIndexScanText(nwMinimal, dummyGUI): "% 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, dummyGUI): +# END Test testCoreIndex_ScanText + +@pytest.mark.core +def testCoreIndex_ExtractData(nwMinimal, dummyGUI): """Check the index data extraction functions. """ theProject = NWProject(dummyGUI) @@ -424,7 +433,13 @@ def testIndexExtractData(nwMinimal, dummyGUI): )) # 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, dummyGUI): # 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, dummyGUI): # 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, dummyGUI): assert pC == 2 assert theProject.closeProject() + +# END Test testCoreIndex_ExtractData diff --git a/tests/test_core_tree.py b/tests/test_core_tree.py index c3b68964..447ff1cc 100644 --- a/tests/test_core_tree.py +++ b/tests/test_core_tree.py @@ -239,7 +239,7 @@ def testCoreTree_Methods(dummyGUI, dummyItems): # END Test testCoreTree_Methods @pytest.mark.core -def testCoreTree_MakeHandles(dummyGUI, monkeypatch): +def testCoreTree_MakeHandles(monkeypatch, dummyGUI): """Test generating item handles. """ theProject = NWProject(dummyGUI) @@ -384,7 +384,7 @@ def testCoreTree_XMLPackUnpack(dummyGUI, dummyItems): # END Test testCoreTree_XMLPackUnpack @pytest.mark.core -def testCoreTree_ToCFile(dummyGUI, dummyItems, tmpDir, monkeypatch): +def testCoreTree_ToCFile(monkeypatch, dummyGUI, dummyItems, tmpDir): """Test writing the ToC.txt file. """ theProject = NWProject(dummyGUI) From afea7b66bde8e9a5236747424afe7ce0bdfecfd2 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 1 Dec 2020 10:50:41 +0100 Subject: [PATCH 18/52] Rename nwRef to refDir --- tests/conftest.py | 26 ++++++++----------- tests/test_config.py | 22 ++++++++-------- tests/test_core_index.py | 4 +-- tests/test_dialogs.py | 56 ++++++++++++++++++++-------------------- tests/test_gui.py | 14 +++++----- tests/test_project.py | 20 +++++++------- 6 files changed, 69 insertions(+), 73 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 863818eb..2d2ec6e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,6 +33,15 @@ def tmpDir(): os.mkdir(tempDir) return tempDir +@pytest.fixture(scope="session") +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 ## @@ -56,30 +65,17 @@ def dummyGUI(tmpConf): # =============================================================================================== # -## -# Core Test Folders -## - -@pytest.fixture(scope="session") -def nwRef(): - """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 ## @pytest.fixture(scope="session") -def nwConf(nwRef, tmpDir): +def nwConf(refDir, tmpDir): """Temporary novelWriter configuration used for the dummy instance of novelWriter's main GUI. """ theConf = Config() - theConf.initConfig(nwRef, tmpDir) + theConf.initConfig(refDir, tmpDir) return theConf ## diff --git a/tests/test_config.py b/tests/test_config.py index ad83ddd5..c23f6bc6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,8 +8,8 @@ import os from nwtools import cmpFiles @pytest.mark.core -def testConfigCore(tmpConf, tmpDir, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") +def testConfigCore(tmpConf, tmpDir, refDir): + refConf = os.path.join(refDir, "novelwriter.conf") testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") assert tmpConf.confPath == tmpDir @@ -38,8 +38,8 @@ def testConfigSetDataPath(tmpConf, tmpDir): assert not tmpConf.confChanged @pytest.mark.core -def testConfigSetWinSize(tmpConf, tmpDir, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") +def testConfigSetWinSize(tmpConf, tmpDir, refDir): + refConf = os.path.join(refDir, "novelwriter.conf") testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") tmpConf.guiScale = 1.0 @@ -55,8 +55,8 @@ def testConfigSetWinSize(tmpConf, tmpDir, nwRef): assert not tmpConf.confChanged @pytest.mark.core -def testConfigSetTreeColWidths(tmpConf, tmpDir, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") +def testConfigSetTreeColWidths(tmpConf, tmpDir, refDir): + refConf = os.path.join(refDir, "novelwriter.conf") testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") assert tmpConf.confPath == tmpDir @@ -77,8 +77,8 @@ def testConfigSetTreeColWidths(tmpConf, tmpDir, nwRef): assert not tmpConf.confChanged @pytest.mark.core -def testConfigSetPanePos(tmpConf, tmpDir, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") +def testConfigSetPanePos(tmpConf, tmpDir, refDir): + refConf = os.path.join(refDir, "novelwriter.conf") testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") assert tmpConf.confPath == tmpDir @@ -113,8 +113,8 @@ def testConfigSetPanePos(tmpConf, tmpDir, nwRef): assert not tmpConf.confChanged @pytest.mark.core -def testConfigFlags(tmpConf, tmpDir, nwRef): - refConf = os.path.join(nwRef, "novelwriter.conf") +def testConfigFlags(tmpConf, tmpDir, refDir): + refConf = os.path.join(refDir, "novelwriter.conf") testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") assert tmpConf.confPath == tmpDir @@ -137,7 +137,7 @@ def testConfigFlags(tmpConf, tmpDir, nwRef): assert not tmpConf.confChanged @pytest.mark.core -def testTextSizes(tmpConf, tmpDir, nwRef): +def testTextSizes(tmpConf, tmpDir, refDir): assert tmpConf.confPath == tmpDir tmpConf.guiScale = 2.0 diff --git a/tests/test_core_index.py b/tests/test_core_index.py index a3bafa2f..74bdd899 100644 --- a/tests/test_core_index.py +++ b/tests/test_core_index.py @@ -15,13 +15,13 @@ from nw.core.index import NWIndex from nw.constants import nwItemClass, nwItemLayout @pytest.mark.core -def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, nwTempProj, nwRef): +def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, nwTempProj, 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, "coreIndex_LoadSave_tagsIndex.json") - compFile = os.path.join(nwRef, "coreIndex_LoadSave_tagsIndex.json") + compFile = os.path.join(refDir, "coreIndex_LoadSave_tagsIndex.json") theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index 5e9a319f..7906c8e8 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -30,7 +30,7 @@ typeDelay = 1 stepDelay = 20 @pytest.mark.gui -def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwRef, tmpDir): +def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, refDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() @@ -137,7 +137,7 @@ def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwR # Check the files projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "2_nwProject.nwx") - refFile = os.path.join(nwRef, "gui", "2_nwProject.nwx") + refFile = os.path.join(refDir, "gui", "2_nwProject.nwx") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [2, 8, 9, 10]) @@ -145,7 +145,7 @@ def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, nwR nwGUI.closeMain() @pytest.mark.gui -def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, nwRef, tmpDir): +def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, refDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() @@ -210,7 +210,7 @@ def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, nwRef, t # Check the files projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "3_nwProject.nwx") - refFile = os.path.join(nwRef, "gui", "3_nwProject.nwx") + refFile = os.path.join(refDir, "gui", "3_nwProject.nwx") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) @@ -397,7 +397,7 @@ def testAboutBox(qtbot, monkeypatch, nwFuncTemp, tmpDir): nwGUI.closeMain() @pytest.mark.gui -def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, tmpDir): +def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, refDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) @@ -428,13 +428,13 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, tmpDir): projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") testFile = os.path.join(nwTempBuild, "1_LoremIpsum.nwd") - refFile = os.path.join(nwRef, "build", "1_LoremIpsum.nwd") + refFile = os.path.join(refDir, "build", "1_LoremIpsum.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") testFile = os.path.join(nwTempBuild, "1_LoremIpsum.htm") - refFile = os.path.join(nwRef, "build", "1_LoremIpsum.htm") + refFile = os.path.join(refDir, "build", "1_LoremIpsum.htm") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -467,13 +467,13 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, tmpDir): projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") testFile = os.path.join(nwTempBuild, "2_LoremIpsum.nwd") - refFile = os.path.join(nwRef, "build", "2_LoremIpsum.nwd") + refFile = os.path.join(refDir, "build", "2_LoremIpsum.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") testFile = os.path.join(nwTempBuild, "2_LoremIpsum.htm") - refFile = os.path.join(nwRef, "build", "2_LoremIpsum.htm") + refFile = os.path.join(refDir, "build", "2_LoremIpsum.htm") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -487,14 +487,14 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, tmpDir): assert nwBuild._saveDocument(nwBuild.FMT_NWD) projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") testFile = os.path.join(nwTempBuild, "3_LoremIpsum.nwd") - refFile = os.path.join(nwRef, "build", "3_LoremIpsum.nwd") + refFile = os.path.join(refDir, "build", "3_LoremIpsum.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) assert nwBuild._saveDocument(nwBuild.FMT_HTM) projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") testFile = os.path.join(nwTempBuild, "3_LoremIpsum.htm") - refFile = os.path.join(nwRef, "build", "3_LoremIpsum.htm") + refFile = os.path.join(refDir, "build", "3_LoremIpsum.htm") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -521,14 +521,14 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, tmpDir): assert nwBuild._saveDocument(nwBuild.FMT_NWD) projFile = os.path.join(nwLipsum, "Lorem Ipsum.nwd") testFile = os.path.join(nwTempBuild, "4_LoremIpsum.nwd") - refFile = os.path.join(nwRef, "build", "4_LoremIpsum.nwd") + refFile = os.path.join(refDir, "build", "4_LoremIpsum.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) assert nwBuild._saveDocument(nwBuild.FMT_HTM) projFile = os.path.join(nwLipsum, "Lorem Ipsum.htm") testFile = os.path.join(nwTempBuild, "4_LoremIpsum.htm") - refFile = os.path.join(nwRef, "build", "4_LoremIpsum.htm") + refFile = os.path.join(refDir, "build", "4_LoremIpsum.htm") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -536,14 +536,14 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, tmpDir): assert nwBuild._saveDocument(nwBuild.FMT_JSON_H) projFile = os.path.join(nwLipsum, "Lorem Ipsum.json") testFile = os.path.join(nwTempBuild, "4H_LoremIpsum.json") - refFile = os.path.join(nwRef, "build", "4H_LoremIpsum.json") + refFile = os.path.join(refDir, "build", "4H_LoremIpsum.json") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [8]) assert nwBuild._saveDocument(nwBuild.FMT_JSON_M) projFile = os.path.join(nwLipsum, "Lorem Ipsum.json") testFile = os.path.join(nwTempBuild, "4M_LoremIpsum.json") - refFile = os.path.join(nwRef, "build", "4M_LoremIpsum.json") + refFile = os.path.join(refDir, "build", "4M_LoremIpsum.json") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [8]) @@ -589,7 +589,7 @@ def testBuildTool(qtbot, yesToAll, nwTempBuild, nwLipsum, nwRef, tmpDir): nwGUI.closeMain() @pytest.mark.gui -def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef, tmpDir): +def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, refDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % nwLipsum, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) @@ -620,7 +620,7 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef projFile = os.path.join(nwLipsum, "content", "73475cb40a568.nwd") testFile = os.path.join(nwTempGUI, "4_73475cb40a568.nwd") - refFile = os.path.join(nwRef, "gui", "4_73475cb40a568.nwd") + refFile = os.path.join(refDir, "gui", "4_73475cb40a568.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -646,7 +646,7 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef # This should give us back the file as it was before projFile = os.path.join(nwLipsum, "content", "71ee45a3c0db9.nwd") testFile = os.path.join(nwTempGUI, "4_71ee45a3c0db9.nwd") - refFile = os.path.join(nwRef, "gui", "4_73475cb40a568.nwd") + refFile = os.path.join(refDir, "gui", "4_73475cb40a568.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [1, 2, 3]) @@ -670,19 +670,19 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef projFile = os.path.join(nwLipsum, "content", "25fc0e7096fc6.nwd") testFile = os.path.join(nwTempGUI, "5_25fc0e7096fc6.nwd") - refFile = os.path.join(nwRef, "gui", "5_25fc0e7096fc6.nwd") + refFile = os.path.join(refDir, "gui", "5_25fc0e7096fc6.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "31489056e0916.nwd") testFile = os.path.join(nwTempGUI, "5_31489056e0916.nwd") - refFile = os.path.join(nwRef, "gui", "5_31489056e0916.nwd") + refFile = os.path.join(refDir, "gui", "5_31489056e0916.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "98010bd9270f9.nwd") testFile = os.path.join(nwTempGUI, "5_98010bd9270f9.nwd") - refFile = os.path.join(nwRef, "gui", "5_98010bd9270f9.nwd") + refFile = os.path.join(refDir, "gui", "5_98010bd9270f9.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -708,31 +708,31 @@ def testMergeSplitTools(qtbot, monkeypatch, yesToAll, nwTempGUI, nwLipsum, nwRef projFile = os.path.join(nwLipsum, "content", "1a6562590ef19.nwd") testFile = os.path.join(nwTempGUI, "5_25fc0e7096fc6.nwd") - refFile = os.path.join(nwRef, "gui", "5_25fc0e7096fc6.nwd") + refFile = os.path.join(refDir, "gui", "5_25fc0e7096fc6.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [1, 2, 3]) projFile = os.path.join(nwLipsum, "content", "031b4af5197ec.nwd") testFile = os.path.join(nwTempGUI, "5_031b4af5197ec.nwd") - refFile = os.path.join(nwRef, "gui", "5_031b4af5197ec.nwd") + refFile = os.path.join(refDir, "gui", "5_031b4af5197ec.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "41cfc0d1f2d12.nwd") testFile = os.path.join(nwTempGUI, "5_41cfc0d1f2d12.nwd") - refFile = os.path.join(nwRef, "gui", "5_41cfc0d1f2d12.nwd") + refFile = os.path.join(refDir, "gui", "5_41cfc0d1f2d12.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "2858dcd1057d3.nwd") testFile = os.path.join(nwTempGUI, "5_2858dcd1057d3.nwd") - refFile = os.path.join(nwRef, "gui", "5_2858dcd1057d3.nwd") + refFile = os.path.join(refDir, "gui", "5_2858dcd1057d3.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwLipsum, "content", "2fca346db6561.nwd") testFile = os.path.join(nwTempGUI, "5_2fca346db6561.nwd") - refFile = os.path.join(nwRef, "gui", "5_2fca346db6561.nwd") + refFile = os.path.join(refDir, "gui", "5_2fca346db6561.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) @@ -1017,7 +1017,7 @@ def testLoadProject(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir): nwGUI.close() @pytest.mark.gui -def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir, nwRef, tmpConf): +def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir, refDir, tmpConf): nwGUI = nw.main(["--testmode", "--config=%s" % nwMinimal, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() @@ -1203,7 +1203,7 @@ def testPreferences(qtbot, monkeypatch, yesToAll, nwMinimal, tmpDir, nwRef, tmpC # qtbot.stopForInteraction() nwGUI.closeMain() - refConf = os.path.join(nwRef, "novelwriter_prefs.conf") + refConf = os.path.join(refDir, "novelwriter_prefs.conf") projConf = os.path.join(nwGUI.mainConf.confPath, "novelwriter.conf") testConf = os.path.join(tmpDir, "novelwriter_prefs.conf") copyfile(projConf, testConf) diff --git a/tests/test_gui.py b/tests/test_gui.py index 2b10b5de..014d8f34 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -103,7 +103,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, tmpDir): monkeypatch.undo() @pytest.mark.gui -def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, tmpDir): +def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, refDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) @@ -132,7 +132,7 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, tmpDir): # Check the files projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "0_nwProject.nwx") - refFile = os.path.join(nwRef, "gui", "0_nwProject.nwx") + refFile = os.path.join(refDir, "gui", "0_nwProject.nwx") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) qtbot.wait(stepDelay) @@ -381,31 +381,31 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, nwRef, tmpDir): # Check the files projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "1_nwProject.nwx") - refFile = os.path.join(nwRef, "gui", "1_nwProject.nwx") + refFile = os.path.join(refDir, "gui", "1_nwProject.nwx") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) projFile = os.path.join(nwFuncTemp, "content", "031b4af5197ec.nwd") testFile = os.path.join(nwTempGUI, "1_031b4af5197ec.nwd") - refFile = os.path.join(nwRef, "gui", "1_031b4af5197ec.nwd") + refFile = os.path.join(refDir, "gui", "1_031b4af5197ec.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwFuncTemp, "content", "1a6562590ef19.nwd") testFile = os.path.join(nwTempGUI, "1_1a6562590ef19.nwd") - refFile = os.path.join(nwRef, "gui", "1_1a6562590ef19.nwd") + refFile = os.path.join(refDir, "gui", "1_1a6562590ef19.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwFuncTemp, "content", "0e17daca5f3e1.nwd") testFile = os.path.join(nwTempGUI, "1_0e17daca5f3e1.nwd") - refFile = os.path.join(nwRef, "gui", "1_0e17daca5f3e1.nwd") + refFile = os.path.join(refDir, "gui", "1_0e17daca5f3e1.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) projFile = os.path.join(nwFuncTemp, "content", "41cfc0d1f2d12.nwd") testFile = os.path.join(nwTempGUI, "1_41cfc0d1f2d12.nwd") - refFile = os.path.join(nwRef, "gui", "1_41cfc0d1f2d12.nwd") + refFile = os.path.join(refDir, "gui", "1_41cfc0d1f2d12.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) diff --git a/tests/test_project.py b/tests/test_project.py index b8a49aa9..0ca04f91 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -16,12 +16,12 @@ from nw.core.spellcheck import NWSpellEnchant, NWSpellSimple from nw.constants import nwConst, nwItemClass, nwItemType, nwItemLayout, nwFiles @pytest.mark.project -def testProjectNewOpenSave(nwFuncTemp, nwTempProj, nwRef, tmpDir, dummyGUI): +def testProjectNewOpenSave(nwFuncTemp, nwTempProj, refDir, tmpDir, dummyGUI): """Test that a basic project can be created, and opened and saved. """ projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempProj, "1_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "1_nwProject.nwx") + refFile = os.path.join(refDir, "proj", "1_nwProject.nwx") theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) @@ -62,12 +62,12 @@ def testProjectNewOpenSave(nwFuncTemp, nwTempProj, nwRef, tmpDir, dummyGUI): assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) @pytest.mark.project -def testProjectNewRoot(nwFuncTemp, nwTempProj, nwRef, dummyGUI): +def testProjectNewRoot(nwFuncTemp, nwTempProj, refDir, dummyGUI): """Check that new root folders can be added to the project. """ projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempProj, "2_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "2_nwProject.nwx") + refFile = os.path.join(refDir, "proj", "2_nwProject.nwx") theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) @@ -96,12 +96,12 @@ def testProjectNewRoot(nwFuncTemp, nwTempProj, nwRef, dummyGUI): assert not theProject.projChanged @pytest.mark.project -def testProjectNewFile(nwFuncTemp, nwTempProj, nwRef, dummyGUI): +def testProjectNewFile(nwFuncTemp, nwTempProj, refDir, dummyGUI): """Check that new files can be added to the project. """ projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempProj, "3_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "3_nwProject.nwx") + refFile = os.path.join(refDir, "proj", "3_nwProject.nwx") theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) @@ -123,13 +123,13 @@ def testProjectNewFile(nwFuncTemp, nwTempProj, nwRef, dummyGUI): assert not theProject.projChanged @pytest.mark.project -def testProjectNewCustomA(nwFuncTemp, nwTempProj, nwRef, dummyGUI): +def testProjectNewCustomA(nwFuncTemp, nwTempProj, refDir, dummyGUI): """Create a new project from a project wizard dictionary. Custom type with chapters and scenes. """ projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempProj, "4_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "4_nwProject.nwx") + refFile = os.path.join(refDir, "proj", "4_nwProject.nwx") projData = { "projName": "Test Custom", @@ -162,13 +162,13 @@ def testProjectNewCustomA(nwFuncTemp, nwTempProj, nwRef, dummyGUI): assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) @pytest.mark.project -def testProjectNewCustomB(nwFuncTemp, nwTempProj, nwRef, dummyGUI): +def testProjectNewCustomB(nwFuncTemp, nwTempProj, refDir, dummyGUI): """Create a new project from a project wizard dictionary. Custom type without chapters, but with scenes. """ projFile = os.path.join(nwFuncTemp, "nwProject.nwx") testFile = os.path.join(nwTempProj, "5_nwProject.nwx") - refFile = os.path.join(nwRef, "proj", "5_nwProject.nwx") + refFile = os.path.join(refDir, "proj", "5_nwProject.nwx") projData = { "projName": "Test Custom", From 2018d44ba85b537000e054783b0bc0989f073cef Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 1 Dec 2020 21:00:31 +0100 Subject: [PATCH 19/52] Some changes to fixtures and added tests README --- tests/README.md | 68 ++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 25 ++++++++++----- tests/test_core_index.py | 4 +-- 3 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 tests/README.md diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..6a2a792b --- /dev/null +++ b/tests/README.md @@ -0,0 +1,68 @@ +# 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 test 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 | Core functions | nw/core/tools.py | `-m core` | `-k testCoreTools` | +| Unit | NWIndex class | nw/core/index.py | `-m core` | `-k testCoreIndex` | +| Unit | NWItem class | nw/core/item.py | `-m core` | `-k testCoreItem` | +| Unit | NWTree class | nw/core/tree.py | `-m core` | `-k testCoreTree` | + diff --git a/tests/conftest.py b/tests/conftest.py index 2d2ec6e8..0e59a3dc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,12 +26,12 @@ def tmpDir(): 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 refDir(): @@ -39,8 +39,17 @@ def refDir(): the results of tests. """ testDir = os.path.dirname(__file__) - refDir = os.path.join(testDir, "reference") - return refDir + theDir = os.path.join(testDir, "reference") + return theDir + +@pytest.fixture(scope="session") +def outDir(tmpDir): + """An output folder for test results + """ + theDir = os.path.join(tmpDir, "results") + if not os.path.isdir(theDir): + os.mkdir(theDir) + return theDir ## # novelWriter Objects diff --git a/tests/test_core_index.py b/tests/test_core_index.py index 74bdd899..1d577b70 100644 --- a/tests/test_core_index.py +++ b/tests/test_core_index.py @@ -15,12 +15,12 @@ from nw.core.index import NWIndex from nw.constants import nwItemClass, nwItemLayout @pytest.mark.core -def testCoreIndex_LoadSave(monkeypatch, nwLipsum, dummyGUI, nwTempProj, refDir): +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, "coreIndex_LoadSave_tagsIndex.json") + testFile = os.path.join(outDir, "coreIndex_LoadSave_tagsIndex.json") compFile = os.path.join(refDir, "coreIndex_LoadSave_tagsIndex.json") theProject = NWProject(dummyGUI) From 8cc6fa60305fcddf87c34badee10ab4766da11e6 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 1 Dec 2020 22:19:18 +0100 Subject: [PATCH 20/52] New test for NWDoc class --- nw/core/document.py | 3 +- tests/README.md | 13 ++-- tests/conftest.py | 122 +++++++++++++++--------------- tests/test_core_document.py | 146 ++++++++++++++++++++++++++++++++++++ tests/test_core_item.py | 3 +- tests/test_project.py | 24 ------ 6 files changed, 218 insertions(+), 93 deletions(-) create mode 100644 tests/test_core_document.py 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/tests/README.md b/tests/README.md index 6a2a792b..7f9e737c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -59,10 +59,11 @@ Available markers are: 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 | Core functions | nw/core/tools.py | `-m core` | `-k testCoreTools` | -| Unit | NWIndex class | nw/core/index.py | `-m core` | `-k testCoreIndex` | -| Unit | NWItem class | nw/core/item.py | `-m core` | `-k testCoreItem` | -| Unit | NWTree class | nw/core/tree.py | `-m core` | `-k testCoreTree` | +| Type | Test Target | Source File(s) | Marker | Filter | +| :--- | :------------- | :------------------ | :-------- | :-------------------- | +| 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 | NWTree class | nw/core/tree.py | `-m core` | `-k testCoreTree` | diff --git a/tests/conftest.py b/tests/conftest.py index 0e59a3dc..3eeb8344 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,68 +72,8 @@ def dummyGUI(tmpConf): theDummy.mainConf = tmpConf return theDummy -# =============================================================================================== # - ## -# novelWriter Objects -## - -@pytest.fixture(scope="session") -def nwConf(refDir, tmpDir): - """Temporary novelWriter configuration used for the dummy instance - of novelWriter's main GUI. - """ - theConf = Config() - theConf.initConfig(refDir, tmpDir) - return theConf - -## -# Temporary Test Folders -## - -@pytest.fixture(scope="session") -def nwTempProj(tmpDir): - """A temporary folder for project tests. - """ - projDir = os.path.join(tmpDir, "proj") - if not os.path.isdir(projDir): - os.mkdir(projDir) - return projDir - -@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 - -@pytest.fixture(scope="function") -def nwFuncTemp(tmpDir): - """A temporary folder for a single test function. - """ - funcDir = os.path.join(tmpDir, "ftemp") - if os.path.isdir(funcDir): - shutil.rmtree(funcDir) - if not os.path.isdir(funcDir): - os.mkdir(funcDir) - yield funcDir - if os.path.isdir(funcDir): - shutil.rmtree(funcDir) - return - -## -# Temp Folders for Projects +# Temp Project Folders ## @pytest.fixture(scope="function") @@ -215,3 +155,63 @@ def yesToAll(monkeypatch): QMessageBox, "critical", lambda *args, **kwargs: QMessageBox.Yes ) return + +# =============================================================================================== # + +## +# novelWriter Objects +## + +@pytest.fixture(scope="session") +def nwConf(refDir, tmpDir): + """Temporary novelWriter configuration used for the dummy instance + of novelWriter's main GUI. + """ + theConf = Config() + theConf.initConfig(refDir, tmpDir) + return theConf + +## +# Temporary Test Folders +## + +@pytest.fixture(scope="session") +def nwTempProj(tmpDir): + """A temporary folder for project tests. + """ + projDir = os.path.join(tmpDir, "proj") + if not os.path.isdir(projDir): + os.mkdir(projDir) + return projDir + +@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 + +@pytest.fixture(scope="function") +def nwFuncTemp(tmpDir): + """A temporary folder for a single test function. + """ + funcDir = os.path.join(tmpDir, "ftemp") + if os.path.isdir(funcDir): + shutil.rmtree(funcDir) + if not os.path.isdir(funcDir): + os.mkdir(funcDir) + yield funcDir + if os.path.isdir(funcDir): + shutil.rmtree(funcDir) + return diff --git a/tests/test_core_document.py b/tests/test_core_document.py new file mode 100644 index 00000000..904c9d25 --- /dev/null +++ b/tests/test_core_document.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +"""novelWriter NWDoc Class Tester +""" + +import os +import pytest + +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 + def dummyIO(*args, **kwargs): + raise OSError + + monkeypatch.setattr("builtins.open", dummyIO) + 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", dummyIO) + 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_core_item.py b/tests/test_core_item.py index e1457792..d592f7c0 100644 --- a/tests/test_core_item.py +++ b/tests/test_core_item.py @@ -6,7 +6,8 @@ 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.core diff --git a/tests/test_project.py b/tests/test_project.py index 0ca04f91..75c72696 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -325,30 +325,6 @@ def testProjectMethods(monkeypatch, nwMinimal, dummyGUI): assert theProject.setBookAuthors(" Jane Doe \n John Doh \n ") assert theProject.bookAuthors == ["Jane Doe", "John Doh"] -@pytest.mark.project -def testDocMeta(dummyGUI, nwLipsum): - """Check that the document meta data string is parsed correctly. - """ - theProject = NWProject(dummyGUI) - theProject.projTree.setSeed(42) - assert theProject.openProject(nwLipsum) - - aDoc = NWDoc(theProject, dummyGUI) - assert aDoc.openDocument("47666c91c7ccf") - theName, theParent, theClass, theLayout = aDoc.getMeta() - - assert theName == "Scene Five" - assert theParent == "6bd935d2490cd" - assert theClass == nwItemClass.NOVEL - assert theLayout == nwItemLayout.SCENE - - aDoc._docMeta = {"stuff": None} - theName, theParent, theClass, theLayout = aDoc.getMeta() - assert theName == "" - assert theParent is None - assert theClass is None - assert theLayout is None - @pytest.mark.project def testSpellEnchant(tmpDir, nwConf): wList = os.path.join(tmpDir, "wordlist.txt") From 7ec43e42fd83ad2188b06a5d9f29b3fe6113a612 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 1 Dec 2020 22:32:48 +0100 Subject: [PATCH 21/52] Fix flake8 and windows test --- tests/test_core_tree.py | 10 +++++++--- tests/test_project.py | 1 - 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_core_tree.py b/tests/test_core_tree.py index 447ff1cc..f417fd6a 100644 --- a/tests/test_core_tree.py +++ b/tests/test_core_tree.py @@ -413,6 +413,10 @@ def testCoreTree_ToCFile(monkeypatch, dummyGUI, dummyItems, tmpDir): theProject.projPath = tmpDir assert theTree.writeToCFile() + pathA = os.path.join("content", "c000000000001.nwd") + pathB = os.path.join("content", "c000000000002.nwd") + pathC = os.path.join("content", "b000000000002.nwd") + with open(os.path.join(tmpDir, nwFiles.TOC_TXT), mode="r", encoding="utf8") as inFile: assert inFile.read() == ( "\n" @@ -421,9 +425,9 @@ def testCoreTree_ToCFile(monkeypatch, dummyGUI, dummyItems, tmpDir): "\n" "File Name Class Layout Document Label\n" "-------------------------------------------------------------\n" - "content/c000000000001.nwd NOVEL UNNUMBERED Chapter One\n" - "content/c000000000002.nwd NOVEL SCENE Scene One\n" - "content/b000000000002.nwd CHARACTER NOTE Jane Doe\n" + f"{pathA} NOVEL UNNUMBERED Chapter One\n" + f"{pathB} NOVEL SCENE Scene One\n" + f"{pathC} CHARACTER NOTE Jane Doe\n" ) # END Test testCoreTree_ToCFile diff --git a/tests/test_project.py b/tests/test_project.py index 75c72696..73c65f13 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -11,7 +11,6 @@ from zipfile import ZipFile from nwtools import cmpFiles from nw.core.project import NWProject -from nw.core.document import NWDoc from nw.core.spellcheck import NWSpellEnchant, NWSpellSimple from nw.constants import nwConst, nwItemClass, nwItemType, nwItemLayout, nwFiles From fba4d5d04db3c1197adc17e36c9ccd16aad0c236 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 1 Dec 2020 23:27:29 +0100 Subject: [PATCH 22/52] New test for OptionsState class --- nw/core/options.py | 18 +---- tests/README.md | 15 +++-- tests/test_core_options.py | 134 +++++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 22 deletions(-) create mode 100644 tests/test_core_options.py 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/tests/README.md b/tests/README.md index 7f9e737c..a559e00c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -59,11 +59,12 @@ Available markers are: 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 | 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 | NWTree class | nw/core/tree.py | `-m core` | `-k testCoreTree` | +| Type | Test Target | Source File(s) | Marker | Filter | +| :--- | :----------------- | :------------------ | :-------- | :-------------------- | +| 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 | NWTree class | nw/core/tree.py | `-m core` | `-k testCoreTree` | +| Unit | OptionsState class | nw/core/options.py | `-m core` | `-k testCoreOptions` | diff --git a/tests/test_core_options.py b/tests/test_core_options.py new file mode 100644 index 00000000..20830d69 --- /dev/null +++ b/tests/test_core_options.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""novelWriter OptionState Class Tester +""" + +import os +import json +import pytest + +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 + def dummyIO(*args, **kwargs): + raise OSError + + monkeypatch.setattr("builtins.open", dummyIO) + 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 From c13bd18271b6838a079d05e566a1b9edb2a8a76d Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Thu, 3 Dec 2020 17:39:42 +0100 Subject: [PATCH 23/52] Remove nw prefix from common test files --- tests/conftest.py | 2 +- tests/{nwdummy.py => dummy.py} | 12 ++++ tests/test_common.py | 2 +- tests/test_config.py | 2 +- tests/test_core_index.py | 2 +- tests/test_dialogs.py | 2 +- tests/test_gui.py | 2 +- tests/test_project.py | 123 +-------------------------------- tests/{nwtools.py => tools.py} | 0 9 files changed, 19 insertions(+), 128 deletions(-) rename tests/{nwdummy.py => dummy.py} (60%) rename tests/{nwtools.py => tools.py} (100%) diff --git a/tests/conftest.py b/tests/conftest.py index 3eeb8344..3a97c58f 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 diff --git a/tests/nwdummy.py b/tests/dummy.py similarity index 60% rename from tests/nwdummy.py rename to tests/dummy.py index 4b5302b7..c97be669 100644 --- a/tests/nwdummy.py +++ b/tests/dummy.py @@ -2,6 +2,10 @@ """novelWriter Test Dummy GUI Classes """ +# =========================================================================== # +# Mock GUI +# =========================================================================== # + class DummyMain(): def __init__(self): @@ -37,3 +41,11 @@ class StatusBar(): return # END Class StatusBar + +# =========================================================================== # +# Error Functions +# Dummy functions that will raise errors instead. +# =========================================================================== # + +def dummyIO(*args, **kwargs): + raise OSError diff --git a/tests/test_common.py b/tests/test_common.py index 2a24f31f..135627fc 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -9,7 +9,7 @@ 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(): diff --git a/tests/test_config.py b/tests/test_config.py index c23f6bc6..94ff5d1c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -5,7 +5,7 @@ import pytest import os -from nwtools import cmpFiles +from tools import cmpFiles @pytest.mark.core def testConfigCore(tmpConf, tmpDir, refDir): diff --git a/tests/test_core_index.py b/tests/test_core_index.py index 1d577b70..ad87c7f3 100644 --- a/tests/test_core_index.py +++ b/tests/test_core_index.py @@ -8,7 +8,7 @@ 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 diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index 7906c8e8..b8e3b276 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -9,7 +9,7 @@ import os import sys from shutil import copyfile -from nwtools import cmpFiles, getGuiItem +from tools import cmpFiles, getGuiItem from PyQt5.QtCore import Qt, QItemSelectionModel from PyQt5.QtWidgets import ( diff --git a/tests/test_gui.py b/tests/test_gui.py index 014d8f34..a6438d0a 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -9,7 +9,7 @@ import os import sys from shutil import copyfile -from nwtools import cmpFiles +from tools import cmpFiles from PyQt5.QtCore import Qt, QUrl, QPoint, QItemSelectionModel from PyQt5.QtGui import QTextCursor, QColor, QPixmap, QIcon, QTextBlock diff --git a/tests/test_project.py b/tests/test_project.py index 73c65f13..4167dbd3 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -8,7 +8,7 @@ import os from shutil import copyfile from zipfile import ZipFile -from nwtools import cmpFiles +from tools import cmpFiles from nw.core.project import NWProject from nw.core.spellcheck import NWSpellEnchant, NWSpellSimple @@ -324,127 +324,6 @@ def testProjectMethods(monkeypatch, nwMinimal, dummyGUI): assert theProject.setBookAuthors(" Jane Doe \n John Doh \n ") assert theProject.bookAuthors == ["Jane Doe", "John Doh"] -@pytest.mark.project -def testSpellEnchant(tmpDir, nwConf): - wList = os.path.join(tmpDir, "wordlist.txt") - with open(wList, mode="w") as wFile: - wFile.write("a_word\nb_word\nc_word\n") - - spChk = NWSpellEnchant() - spChk.mainConf = nwConf - spChk.setLanguage("en", wList) - - assert spChk.checkWord("a_word") - assert spChk.checkWord("b_word") - assert spChk.checkWord("c_word") - assert not spChk.checkWord("d_word") - - spChk.addWord("d_word") - assert spChk.checkWord("d_word") - - wSuggest = spChk.suggestWords("wrod") - assert len(wSuggest) > 0 - assert "word" in wSuggest - - dList = spChk.listDictionaries() - assert len(dList) > 0 - - aTag, aName = spChk.describeDict() - assert aTag == "en" - assert aName != "" - -@pytest.mark.project -def testSpellSimple(tmpDir, nwConf): - wList = os.path.join(tmpDir, "wordlist.txt") - with open(wList, mode="w") as wFile: - wFile.write("a_word\nb_word\nc_word\n") - - spChk = NWSpellSimple() - spChk.mainConf = nwConf - spChk.setLanguage("en", wList) - - assert spChk.checkWord("a_word") - assert spChk.checkWord("b_word") - assert spChk.checkWord("c_word") - assert not spChk.checkWord("d_word") - - spChk.addWord("d_word") - assert spChk.checkWord("d_word") - - wSuggest = spChk.suggestWords("wrod") - assert len(wSuggest) > 0 - assert "word" in wSuggest - - dList = spChk.listDictionaries() - assert len(dList) > 0 - - aTag, aName = spChk.describeDict() - assert aTag == "en" - assert aName == nwConst.SP_INTERNAL - -@pytest.mark.project -def testProjectOptions(dummyGUI, nwLipsum): - """Test the class that holds all the GUI state user options that are - tied to the current open project. Non-project related GUI options - are handled by the Config class. - """ - theProject = NWProject(dummyGUI) - assert theProject.projMeta is None - - theOpts = theProject.optState - assert not theOpts.loadSettings() - assert not theOpts.saveSettings() - - # No Settings - assert theProject.openProject(nwLipsum) - assert theOpts.loadSettings() - assert theOpts.saveSettings() - assert str(theOpts.theState) == r"{}" - - # Read Invalid Settings and Filter - stateFile = os.path.join(theProject.projMeta, nwFiles.OPTS_FILE) - with open(stateFile, mode="w", encoding="utf8") as outFile: - outFile.write( - r'{"GuiProjectSettings": {"winWidth": 100, "winHeight": 50}, "NoGroup": {"NoName": 0}}' - ) - assert theOpts.loadSettings() - assert str(theOpts.theState) == r"{'GuiProjectSettings': {'winWidth': 100, 'winHeight': 50}}" - - # Set New Settings - assert not theOpts.setValue("NoGroup", "NoName", None) - assert not theOpts.setValue("GuiProjectSettings", "NoName", None) - assert theOpts.setValue("GuiProjectSettings", "winWidth", 200) - assert theOpts.setValue("GuiProjectSettings", "winHeight", 80) - assert str(theOpts.theState) == r"{'GuiProjectSettings': {'winWidth': 200, 'winHeight': 80}}" - - # Check Read/Write Types - - ## String - assert theOpts.setValue("GuiWritingStats", "winWidth", "123") - assert isinstance(theOpts.getString("GuiWritingStats", "winWidth", "456"), str) - assert theOpts.getString("GuiWritingStats", "NoName", "456") == "456" - - ## Int - assert theOpts.setValue("GuiWritingStats", "winWidth", "123") - assert isinstance(theOpts.getInt("GuiWritingStats", "winWidth", 456), int) - assert theOpts.getInt("GuiWritingStats", "NoName", 456) == 456 - assert theOpts.setValue("GuiWritingStats", "winWidth", "True") - assert theOpts.getInt("GuiWritingStats", "NoName", 456) == 456 - - ## Float - assert theOpts.setValue("GuiWritingStats", "winWidth", "123") - assert isinstance(theOpts.getFloat("GuiWritingStats", "winWidth", 456.0), float) - assert theOpts.getFloat("GuiWritingStats", "NoName", 456.0) == 456.0 - assert theOpts.setValue("GuiWritingStats", "winWidth", "True") - assert theOpts.getFloat("GuiWritingStats", "winWidth", 456.0) == 456.0 - - ## Bool - assert theOpts.setValue("GuiWritingStats", "winWidth", True) - assert isinstance(theOpts.getBool("GuiWritingStats", "winWidth", False), bool) - assert theOpts.getFloat("GuiWritingStats", "NoName", False) is False - assert theOpts.setValue("GuiWritingStats", "winWidth", "True") - assert theOpts.getFloat("GuiWritingStats", "winWidth", False) is False - @pytest.mark.project def testProjectOrphanedFiles(dummyGUI, nwLipsum): """Check that files in the content folder that are not tracked in diff --git a/tests/nwtools.py b/tests/tools.py similarity index 100% rename from tests/nwtools.py rename to tests/tools.py From c7c70a35a08d17b690823fa038bb7415e0c786d7 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Thu, 3 Dec 2020 18:37:52 +0100 Subject: [PATCH 24/52] Updated tests for NWSpell* classes --- nw/core/spellcheck.py | 32 ++++--- tests/README.md | 18 ++-- tests/dummy.py | 2 +- tests/test_core_document.py | 9 +- tests/test_core_options.py | 7 +- tests/test_core_spell.py | 172 ++++++++++++++++++++++++++++++++++++ tests/tools.py | 12 +++ 7 files changed, 219 insertions(+), 33 deletions(-) create mode 100644 tests/test_core_spell.py 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/tests/README.md b/tests/README.md index a559e00c..51dd9dca 100644 --- a/tests/README.md +++ b/tests/README.md @@ -59,12 +59,12 @@ Available markers are: 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 | 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 | NWTree class | nw/core/tree.py | `-m core` | `-k testCoreTree` | -| Unit | OptionsState class | nw/core/options.py | `-m core` | `-k testCoreOptions` | - +| Type | Test Target | Source File(s) | Marker | Filter | +| :--- | :----------------- | :-------------------- | :-------- | :-------------------- | +| 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 | NWSpell* classes | nw/core/spellcheck.py | `-m core` | `-k testCoreSpell` | +| Unit | NWTree class | nw/core/tree.py | `-m core` | `-k testCoreTree` | +| Unit | OptionsState class | nw/core/options.py | `-m core` | `-k testCoreOptions` | diff --git a/tests/dummy.py b/tests/dummy.py index c97be669..fbb91cbd 100644 --- a/tests/dummy.py +++ b/tests/dummy.py @@ -47,5 +47,5 @@ class StatusBar(): # Dummy functions that will raise errors instead. # =========================================================================== # -def dummyIO(*args, **kwargs): +def causeOSError(*args, **kwargs): raise OSError diff --git a/tests/test_core_document.py b/tests/test_core_document.py index 904c9d25..9e16c66e 100644 --- a/tests/test_core_document.py +++ b/tests/test_core_document.py @@ -5,6 +5,8 @@ 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 @@ -75,10 +77,7 @@ def testCoreDocument_LoadSave(monkeypatch, dummyGUI, nwMinimal): assert inFile.read() == theText # Cause open() to fail while saving - def dummyIO(*args, **kwargs): - raise OSError - - monkeypatch.setattr("builtins.open", dummyIO) + monkeypatch.setattr("builtins.open", causeOSError) assert not theDoc.saveDocument(theText) monkeypatch.undo() @@ -91,7 +90,7 @@ def testCoreDocument_LoadSave(monkeypatch, dummyGUI, nwMinimal): assert os.path.isfile(docPath) # Cause the delete to fail - monkeypatch.setattr("os.unlink", dummyIO) + monkeypatch.setattr("os.unlink", causeOSError) assert not theDoc.deleteDocument(xHandle) monkeypatch.undo() diff --git a/tests/test_core_options.py b/tests/test_core_options.py index 20830d69..963a12c4 100644 --- a/tests/test_core_options.py +++ b/tests/test_core_options.py @@ -6,6 +6,8 @@ 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 @@ -44,10 +46,7 @@ def testCoreOptions_LoadSave(monkeypatch, dummyGUI, tmpDir): assert theProject.projMeta == tmpDir # Cause open() to fail - def dummyIO(*args, **kwargs): - raise OSError - - monkeypatch.setattr("builtins.open", dummyIO) + monkeypatch.setattr("builtins.open", causeOSError) assert not theOpts.loadSettings() assert not theOpts.saveSettings() monkeypatch.undo() diff --git a/tests/test_core_spell.py b/tests/test_core_spell.py new file mode 100644 index 00000000..6b6d7207 --- /dev/null +++ b/tests/test_core_spell.py @@ -0,0 +1,172 @@ +# -*- coding: utf-8 -*- +"""novelWriter Spell Check Class Tester +""" + +import os +import sys +import pytest + +from difflib import get_close_matches + +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/tools.py b/tests/tools.py index e35d9e08..185a9fa0 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -70,3 +70,15 @@ def getGuiItem(theName): if qWidget.objectName() == theName: return qWidget return None + +def readFile(fileName): + """Returns the content of a file as a string. + """ + with open(fileName, mode="r", encoding="utf8") as inFile: + return inFile.read() + +def writeFile(fileName, fileData): + """Write the contents of a string to a file. + """ + with open(fileName, mode="w", encoding="utf8") as outFile: + outFile.write(fileData) From 0cb36c02f3eb0cf32af451fa05ece5c025d29055 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Thu, 3 Dec 2020 20:22:22 +0100 Subject: [PATCH 25/52] New test for NWStatus class --- nw/core/status.py | 16 ++--- tests/README.md | 1 + tests/test_core_spell.py | 2 - tests/test_core_status.py | 126 ++++++++++++++++++++++++++++++++++++++ tests/test_project.py | 3 +- 5 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 tests/test_core_status.py diff --git a/nw/core/status.py b/nw/core/status.py index 7a1c7127..2c6fe811 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 @@ -136,18 +137,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/tests/README.md b/tests/README.md index 51dd9dca..797fa2ea 100644 --- a/tests/README.md +++ b/tests/README.md @@ -66,5 +66,6 @@ The commands for the respective test categories are listed below. | Unit | NWIndex class | nw/core/index.py | `-m core` | `-k testCoreIndex` | | Unit | NWItem class | nw/core/item.py | `-m core` | `-k testCoreItem` | | 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` | diff --git a/tests/test_core_spell.py b/tests/test_core_spell.py index 6b6d7207..1ef94816 100644 --- a/tests/test_core_spell.py +++ b/tests/test_core_spell.py @@ -6,8 +6,6 @@ import os import sys import pytest -from difflib import get_close_matches - from dummy import causeOSError from tools import readFile, writeFile 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_project.py b/tests/test_project.py index 4167dbd3..748021a7 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -11,8 +11,7 @@ from zipfile import ZipFile from tools import cmpFiles from nw.core.project import NWProject -from nw.core.spellcheck import NWSpellEnchant, NWSpellSimple -from nw.constants import nwConst, nwItemClass, nwItemType, nwItemLayout, nwFiles +from nw.constants import nwItemClass, nwItemType, nwItemLayout, nwFiles @pytest.mark.project def testProjectNewOpenSave(nwFuncTemp, nwTempProj, refDir, tmpDir, dummyGUI): From b687ffe6968595cdc997f99e2eb00571cb33266c Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Fri, 4 Dec 2020 00:46:29 +0100 Subject: [PATCH 26/52] New test for Tokenizer class --- nw/core/tokenizer.py | 15 +- tests/README.md | 3 +- tests/lipsum/ToC.json | 77 ----- tests/minimal/ToC.json | 17 - tests/test_core_tokenizer.py | 645 +++++++++++++++++++++++++++++++++++ 5 files changed, 655 insertions(+), 102 deletions(-) delete mode 100644 tests/lipsum/ToC.json delete mode 100644 tests/minimal/ToC.json create mode 100644 tests/test_core_tokenizer.py 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/tests/README.md b/tests/README.md index 797fa2ea..b9c221f9 100644 --- a/tests/README.md +++ b/tests/README.md @@ -52,7 +52,7 @@ pytest-3 -v -m core Available markers are: -* '`core`' for test covering the classes in the `nw/core` folder +* '`core`' for unit tests covering the classes in the `nw/core` folder ## Tests @@ -69,3 +69,4 @@ The commands for the respective test categories are listed below. | 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 | Tokenizer class | nw/core/tokenizer.py | `-m core` | `-k testCoreToken` | 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/test_core_tokenizer.py b/tests/test_core_tokenizer.py new file mode 100644 index 00000000..559f3a1e --- /dev/null +++ b/tests/test_core_tokenizer.py @@ -0,0 +1,645 @@ +# -*- coding: utf-8 -*- +"""novelWriter Tokenizer Class Tester +""" + +import pytest + +from nw.core import NWProject, NWDoc +from nw.core.tokenizer import Tokenizer + +@pytest.mark.core +def testCoreToken_Setters(dummyGUI): + """Test all the setters for the Tokenizer class. + """ + theProject = NWProject(dummyGUI) + theToken = Tokenizer(dummyGUI, theProject) + + # Verify defaults + assert theToken.fmtTitle == "%title%" + assert theToken.fmtChapter == "%title%" + assert theToken.fmtUnNum == "%title%" + assert theToken.fmtScene == "%title%" + assert theToken.fmtSection == "%title%" + assert theToken.hideScene is False + assert theToken.hideSection is False + assert theToken.linkHeaders is False + assert theToken.doBodyText is True + assert theToken.doSynopsis is False + assert theToken.doComments is False + assert theToken.doKeywords is False + assert theToken.doJustify is False + + # Set new values + theToken.setTitleFormat("T: %title%") + theToken.setChapterFormat("C: %title%") + theToken.setUnNumberedFormat("U: %title%") + theToken.setSceneFormat("S: %title%", True) + theToken.setSectionFormat("X: %title%", True) + theToken.setLinkHeaders(True) + theToken.setBodyText(False) + theToken.setSynopsis(True) + theToken.setComments(True) + theToken.setKeywords(True) + theToken.setJustify(True) + + # Check new values + assert theToken.fmtTitle == "T: %title%" + assert theToken.fmtChapter == "C: %title%" + assert theToken.fmtUnNum == "U: %title%" + assert theToken.fmtScene == "S: %title%" + assert theToken.fmtSection == "X: %title%" + assert theToken.hideScene is True + assert theToken.hideSection is True + assert theToken.linkHeaders is True + assert theToken.doBodyText is False + assert theToken.doSynopsis is True + assert theToken.doComments is True + assert theToken.doKeywords is True + assert theToken.doJustify is True + +# END Test testCoreToken_Setters + +@pytest.mark.core +def testCoreToken_TextOps(monkeypatch, nwMinimal, dummyGUI): + """Test handling files and text in the Tokenizer class. + """ + theProject = NWProject(dummyGUI) + theProject.projTree.setSeed(42) + theToken = Tokenizer(theProject, dummyGUI) + + assert theProject.openProject(nwMinimal) + sHandle = "8c659a11cd429" + + # Set some content to work with + + docText = ( + "### Scene Six\n\n" + "This is text with _italic text_, some **bold text**, some ~~deleted text~~, " + "and some **_mixed text_** and **some _nested_ text**.\n\n" + "#### Replace\n\n" + "Also, replace and .\n\n" + ) + docTextR = docText.replace("", "this").replace("", "that") + + nDoc = NWDoc(theProject, dummyGUI) + nDoc.openDocument(sHandle) + nDoc.saveDocument(docText) + nDoc.clearDocument() + + theProject.setAutoReplace({"A": "this", "B": "that"}) + + assert theProject.saveProject() + + # Root heading + assert theToken.addRootHeading("dummy") is False + assert theToken.addRootHeading(sHandle) is False + assert theToken.addRootHeading("7695ce551d265") is True + assert theToken.theMarkdown == "# Notes: Plot\n\n" + + # Set text + assert theToken.setText("dummy") is False + assert theToken.setText(sHandle) is True + assert theToken.theText == docText + + monkeypatch.setattr("nw.constants.nwConst.MAX_DOCSIZE", 100) + assert theToken.setText(sHandle, docText) is True + assert theToken.theText == ( + "# ERROR\n\n" + "Document 'New Scene' is too big (0.00 MB). Skipping.\n\n" + ) + monkeypatch.undo() + + assert theToken.setText(sHandle, docText) is True + assert theToken.theText == docText + + assert theToken.isNone is False + assert theToken.isTitle is False + assert theToken.isBook is False + assert theToken.isPage is False + assert theToken.isPart is False + assert theToken.isUnNum is False + assert theToken.isChap is False + assert theToken.isScene is True + assert theToken.isNote is False + assert theToken.isNovel is True + + # Auto replace + theToken.doAutoReplace() + assert theToken.theText == docTextR + + # Access + assert theToken.getResult() is None + assert theToken.getResultSize() == 0 + theToken.theResult = "" + assert theToken.getResultSize() == 0 + + # Post Processing + theToken.theResult = r"This is text with escapes: \** \~~ \__" + theToken.doPostProcessing() + assert theToken.theResult == "This is text with escapes: ** ~~ __" + +# END Test testCoreToken_TextOps + +@pytest.mark.core +def testCoreToken_Tokenize(dummyGUI): + """Test the tokenization of the Tokenizer class. + """ + theProject = NWProject(dummyGUI) + theToken = Tokenizer(theProject, dummyGUI) + + # Header 1 + theToken.theText = "# Novel Title\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD1, 1, "Novel Title", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "# Novel Title\n\n" + + # Header 2 + theToken.theText = "## Chapter One\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "Chapter One", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "## Chapter One\n\n" + + # Header 3 + theToken.theText = "### Scene One\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD3, 1, "Scene One", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "### Scene One\n\n" + + # Header 4 + theToken.theText = "#### A Section\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD4, 1, "A Section", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "#### A Section\n\n" + + # Comment + theToken.theText = "% A comment\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_COMMENT, 1, "A comment", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "\n" + + theToken.setComments(True) + theToken.tokenizeText() + assert theToken.theMarkdown == "% A comment\n\n" + + # Symopsis + theToken.theText = "%synopsis: The synopsis\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_SYNOPSIS, 1, "The synopsis", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + theToken.theText = "% synopsis: The synopsis\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_SYNOPSIS, 1, "The synopsis", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "\n" + + theToken.setSynopsis(True) + theToken.tokenizeText() + assert theToken.theMarkdown == "% synopsis: The synopsis\n\n" + + # Keyword + theToken.theText = "@char: Bod\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_KEYWORD, 1, "char: Bod", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "\n" + + theToken.setKeywords(True) + theToken.tokenizeText() + assert theToken.theMarkdown == "@char: Bod\n\n" + + # Text + theToken.theText = "Some plain text\non two lines\n\n\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_TEXT, 1, "Some plain text", [], Tokenizer.A_NONE), + (Tokenizer.T_TEXT, 2, "on two lines", [], Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 3, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some plain text\non two lines\n\n\n\n" + + theToken.setBodyText(False) + theToken.tokenizeText() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 3, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 4, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "\n\n\n" + theToken.setBodyText(True) + + # Text Emphasis + theToken.theText = "Some **bolded text** on this lines\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some **bolded text** on this lines", + [ + [5, 2, Tokenizer.FMT_B_B], + [18, 2, Tokenizer.FMT_B_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some **bolded text** on this lines\n\n" + + theToken.theText = "Some _italic text_ on this lines\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some _italic text_ on this lines", + [ + [5, 1, Tokenizer.FMT_I_B], + [17, 1, Tokenizer.FMT_I_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some _italic text_ on this lines\n\n" + + theToken.theText = "Some **_bold italic text_** on this lines\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some **_bold italic text_** on this lines", + [ + [5, 2, Tokenizer.FMT_B_B], + [7, 1, Tokenizer.FMT_I_B], + [24, 1, Tokenizer.FMT_I_E], + [25, 2, Tokenizer.FMT_B_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some **_bold italic text_** on this lines\n\n" + + theToken.theText = "Some ~~strikethrough text~~ on this lines\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some ~~strikethrough text~~ on this lines", + [ + [5, 2, Tokenizer.FMT_D_B], + [25, 2, Tokenizer.FMT_D_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == "Some ~~strikethrough text~~ on this lines\n\n" + + theToken.theText = "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n" + theToken.tokenizeText() + assert theToken.theTokens == [ + ( + Tokenizer.T_TEXT, 1, + "Some **nested bold and _italic_ and ~~strikethrough~~ text** here", + [ + [5, 2, Tokenizer.FMT_B_B], + [23, 1, Tokenizer.FMT_I_B], + [30, 1, Tokenizer.FMT_I_E], + [36, 2, Tokenizer.FMT_D_B], + [51, 2, Tokenizer.FMT_D_E], + [58, 2, Tokenizer.FMT_B_E], + ], + Tokenizer.A_NONE + ), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + assert theToken.theMarkdown == ( + "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n\n" + ) + + # Check the markdown function as well + assert theToken.getFilteredMarkdown() == ( + "Some **nested bold and _italic_ and ~~strikethrough~~ text** here\n\n" + ) + +# END Test testCoreToken_Tokenize + +@pytest.mark.core +def testCoreToken_Headers(dummyGUI): + """Test the header and page parser of the Tokenizer class. + """ + theProject = NWProject(dummyGUI) + theToken = Tokenizer(theProject, dummyGUI) + + # Nothing + theToken.theText = "Some text ...\n" + assert theToken.doHeaders() is True + theToken.isNone = True + assert theToken.doHeaders() is False + theToken.isNone = False + assert theToken.doHeaders() is True + theToken.isNote = True + assert theToken.doHeaders() is False + theToken.isNote = False + + ## + # Novel + ## + + theToken.isNovel = True + + # Titles + # ====== + + # H1: Title + theToken.theText = "# Novel Title\n" + theToken.setTitleFormat(r"T: %title%") + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD1, 1, "T: Novel Title", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # Chapters + # ======== + + # H2: Chapter + theToken.theText = "## Chapter One\n" + theToken.setChapterFormat(r"C: %title%") + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "C: Chapter One", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Unnumbered Chapter + theToken.theText = "## Chapter One\n" + theToken.setUnNumberedFormat(r"U: %title%") + theToken.isUnNum = True + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "U: Chapter One", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Unnumbered Chapter with Star + theToken.theText = "## *Prologue\n" + theToken.setUnNumberedFormat(r"U: %title%") + theToken.isUnNum = False + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "U: Prologue", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Chapter Word Number + theToken.theText = "## Chapter\n" + theToken.setChapterFormat(r"Chapter %chw%") + theToken.numChapter = 0 + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "Chapter One", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Chapter Roman Number Upper Case + theToken.theText = "## Chapter\n" + theToken.setChapterFormat(r"Chapter %chI%") + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "Chapter II", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H2: Chapter Roman Number Lower Case + theToken.theText = "## Chapter\n" + theToken.setChapterFormat(r"Chapter %chi%") + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD2, 1, "Chapter iii", None, Tokenizer.A_PBB), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # Scenes + # ====== + + # H3: Scene w/Title + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"S: %title%", False) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD3, 1, "S: Scene One", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene Hidden wo/Format + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"", True) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene wo/Format, first + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"", False) + theToken.firstScene = True + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene wo/Format, not first + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"", False) + theToken.firstScene = False + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_SKIP, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene Separator, first + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"* * *", False) + theToken.firstScene = True + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene Separator, not first + theToken.theText = "### Scene One\n" + theToken.setSceneFormat(r"* * *", False) + theToken.firstScene = False + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_SEP, 1, "* * *", None, Tokenizer.A_CENTRE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene w/Absolute Number + theToken.theText = "### A Scene\n" + theToken.setSceneFormat(r"Scene %sca%", False) + theToken.numAbsScene = 0 + theToken.numChScene = 0 + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD3, 1, "Scene 1", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H3: Scene w/Chapter Number + theToken.theText = "### A Scene\n" + theToken.setSceneFormat(r"Scene %ch%.%sc%", False) + theToken.numAbsScene = 0 + theToken.numChScene = 1 + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD3, 1, "Scene 3.2", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # Sections + # ======== + + # H4: Section Hidden wo/Format + theToken.theText = "#### A Section\n" + theToken.setSectionFormat(r"", True) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H4: Section Visible wo/Format + theToken.theText = "#### A Section\n" + theToken.setSectionFormat(r"", False) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_SKIP, 1, "", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H4: Section w/Format + theToken.theText = "#### A Section\n" + theToken.setSectionFormat(r"X: %title%", False) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD4, 1, "X: A Section", None, Tokenizer.A_NONE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # H4: Section Separator + theToken.theText = "#### A Section\n" + theToken.setSectionFormat(r"* * *", False) + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_SEP, 1, "* * *", None, Tokenizer.A_CENTRE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_NONE), + ] + + # Check the first scene detector + assert theToken.firstScene is False + theToken.firstScene = True + assert theToken.firstScene is True + theToken.theText = "Some text ...\n" + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.firstScene is False + + ## + # Title or Partition + ## + + theToken.isNovel = False + + # H1: Title + theToken.theText = "# Novel Title\n" + theToken.setTitleFormat(r"T: %title%") + theToken.tokenizeText() + theToken.isTitle = True + theToken.isPart = False + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_TITLE, 1, "Novel Title", None, Tokenizer.A_PBB_NO | Tokenizer.A_CENTRE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_PBA | Tokenizer.A_CENTRE), + ] + + # H1: Partition + theToken.theText = "# Partition Title\n" + theToken.setTitleFormat(r"T: %title%") + theToken.tokenizeText() + theToken.isTitle = False + theToken.isPart = True + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_HEAD1, 1, "Partition Title", None, Tokenizer.A_PBB | Tokenizer.A_CENTRE), + (Tokenizer.T_EMPTY, 1, "", None, Tokenizer.A_PBA | Tokenizer.A_CENTRE), + ] + + ## + # Page + ## + + theToken.isNovel = False + theToken.isTitle = False + theToken.isPart = False + theToken.isPage = True + + # Some Page Text + theToken.theText = "Page text\n\nMore text\n" + theToken.tokenizeText() + theToken.doHeaders() + assert theToken.theTokens == [ + (Tokenizer.T_TEXT, 1, "Page text", [], Tokenizer.A_PBB | Tokenizer.A_LEFT), + (Tokenizer.T_EMPTY, 2, "", None, Tokenizer.A_LEFT), + (Tokenizer.T_TEXT, 3, "More text", [], Tokenizer.A_LEFT), + (Tokenizer.T_EMPTY, 3, "", None, Tokenizer.A_LEFT), + ] + +# END Test testCoreToken_Headers From 91ece2b7f284e0644abdd9d5755b340e2c07e0d6 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Fri, 4 Dec 2020 20:58:44 +0100 Subject: [PATCH 27/52] New test for ToHtml class --- nw/core/tohtml.py | 20 +- nw/gui/docviewer.py | 2 +- nw/guimain.py | 8 +- tests/dummy.py | 2 + tests/test_core_tohtml.py | 373 +++++++++++++++++++++++++++++++++++ tests/test_core_tokenizer.py | 3 +- 6 files changed, 392 insertions(+), 16 deletions(-) create mode 100644 tests/test_core_tohtml.py diff --git a/nw/core/tohtml.py b/nw/core/tohtml.py index df6c4a09..f3166d02 100644 --- a/nw/core/tohtml.py +++ b/nw/core/tohtml.py @@ -68,16 +68,16 @@ class ToHtml(Tokenizer): # Setters ## - def setPreview(self, forPreview, doComments, doSynopsis): + def setPreview(self, doComments, doSynopsis): """If we're using this class to generate markdown preview, we need to make a few changes to formatting, which is managed by these flags. """ - if forPreview: - self.genMode = self.M_PREVIEW - self.doKeywords = True - self.doComments = doComments - self.doSynopsis = doSynopsis + self.genMode = self.M_PREVIEW + self.doKeywords = True + self.doComments = doComments + self.doSynopsis = doSynopsis + return def setStyles(self, cssStyles): @@ -141,11 +141,13 @@ class ToHtml(Tokenizer): # For novel files for export, we bump the titles one level # up as this is more useful for printing and word processor # imports. - h1 = "h1 class='title'" + h1Cl = " class='title'" + h1 = "h1" h2 = "h1" h3 = "h2" h4 = "h3" else: + h1Cl = "" h1 = "h1" h2 = "h2" h3 = "h3" @@ -193,7 +195,7 @@ class ToHtml(Tokenizer): else: aNm = "" - # Process TextType + # Process Text Type if tType == self.T_EMPTY: if parStyle is None: parStyle = "" @@ -214,7 +216,7 @@ class ToHtml(Tokenizer): elif tType == self.T_HEAD1: tHead = tText.replace(r"\\", "
") - tmpResult.append("<%s%s>%s%s\n" % (h1, hStyle, aNm, tHead, h1)) + tmpResult.append("<%s%s%s>%s%s\n" % (h1, h1Cl, hStyle, aNm, tHead, h1)) elif tType == self.T_HEAD2: tHead = tText.replace(r"\\", "
") diff --git a/nw/gui/docviewer.py b/nw/gui/docviewer.py index e74e7ada..f3badad5 100644 --- a/nw/gui/docviewer.py +++ b/nw/gui/docviewer.py @@ -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 diff --git a/nw/guimain.py b/nw/guimain.py index 801de5af..76b344fb 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)) @@ -1073,10 +1073,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/tests/dummy.py b/tests/dummy.py index fbb91cbd..803e4e12 100644 --- a/tests/dummy.py +++ b/tests/dummy.py @@ -11,6 +11,8 @@ class DummyMain(): def __init__(self): self.mainConf = None self.hasProject = True + self.theIndex = None + self.theProject = None self.statusBar = StatusBar() return diff --git a/tests/test_core_tohtml.py b/tests/test_core_tohtml.py new file mode 100644 index 00000000..7c228edf --- /dev/null +++ b/tests/test_core_tohtml.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- +"""novelWriter ToHtml Class Tester +""" + +import pytest + +from nw.core import NWProject, NWIndex, ToHtml + +@pytest.mark.core +def testCoreToHtml_Format(dummyGUI): + """Test all the formatters for the ToHtml class. + """ + theProject = NWProject(dummyGUI) + dummyGUI.theIndex = NWIndex(theProject, dummyGUI) + theHtml = ToHtml(theProject, dummyGUI) + + # Export Mode + # =========== + + assert theHtml._formatSynopsis("synopsis text") == ( + "

Synopsis: synopsis text

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

Comment: comment text

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

Synopsis: synopsis text

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

comment text

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

Title

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

Chapter Title

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

Scene Title

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

Section Title

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

Heading One

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

Heading Two

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

Heading Three

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

Heading Four

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

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

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

Line one
Line two
Line three

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

Synopsis: The synopsis ...

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

Comment: A comment ...

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

" + "A Title

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

* * *

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

 

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

A Title

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

A Title

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

A Title

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

A Title

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

A Title

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

A Title

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

A Title

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

A Title

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

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

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

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

\n" + ) + + # Revert on MD + assert theHtml.theMarkdown == ( + "Text with <brackets> & short–dash, long—dash …\n\n" + ) + theHtml.doPostProcessing() + assert theHtml.theMarkdown == docText + "\n" + + # With Preview, No Revert + theHtml.setPreview(True, True) + theHtml.theText = docText + theHtml.doAutoReplace() + theHtml.tokenizeText() + theHtml.doConvert() + assert theHtml.theMarkdown == ( + "Text with <brackets> & short–dash, long—dash …\n\n" + ) + theHtml.doPostProcessing() + assert theHtml.theMarkdown == ( + "Text with <brackets> & short–dash, long—dash …\n\n" + ) + + # CSS + # === + + assert len(theHtml.getStyleSheet()) > 1 + assert "p {text-align: left;}" in theHtml.getStyleSheet() + assert "p {text-align: justify;}" not in theHtml.getStyleSheet() + + theHtml.setJustify(True) + assert "p {text-align: left;}" not in theHtml.getStyleSheet() + assert "p {text-align: justify;}" in theHtml.getStyleSheet() + + theHtml.setStyles(False) + assert theHtml.getStyleSheet() == [] + +# END Test testCoreToHtml_Methods diff --git a/tests/test_core_tokenizer.py b/tests/test_core_tokenizer.py index 559f3a1e..3d0774b5 100644 --- a/tests/test_core_tokenizer.py +++ b/tests/test_core_tokenizer.py @@ -12,7 +12,7 @@ def testCoreToken_Setters(dummyGUI): """Test all the setters for the Tokenizer class. """ theProject = NWProject(dummyGUI) - theToken = Tokenizer(dummyGUI, theProject) + theToken = Tokenizer(theProject, dummyGUI) # Verify defaults assert theToken.fmtTitle == "%title%" @@ -600,7 +600,6 @@ def testCoreToken_Headers(dummyGUI): # H1: Title theToken.theText = "# Novel Title\n" - theToken.setTitleFormat(r"T: %title%") theToken.tokenizeText() theToken.isTitle = True theToken.isPart = False From 57397fa2bcf419c83de8989cfa8a3eb72198059c Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Fri, 4 Dec 2020 21:28:35 +0100 Subject: [PATCH 28/52] Updated existing NWProject tests --- pytest.ini | 1 - tests/README.md | 1 + tests/conftest.py | 36 ++--- ...roject.nwx => coreProject_1_nwProject.nwx} | 0 ...roject.nwx => coreProject_2_nwProject.nwx} | 0 ...roject.nwx => coreProject_3_nwProject.nwx} | 0 ...roject.nwx => coreProject_4_nwProject.nwx} | 0 ...roject.nwx => coreProject_5_nwProject.nwx} | 0 .../{test_project.py => test_core_project.py} | 150 ++++++++++-------- 9 files changed, 101 insertions(+), 87 deletions(-) rename tests/reference/{proj/1_nwProject.nwx => coreProject_1_nwProject.nwx} (100%) rename tests/reference/{proj/2_nwProject.nwx => coreProject_2_nwProject.nwx} (100%) rename tests/reference/{proj/3_nwProject.nwx => coreProject_3_nwProject.nwx} (100%) rename tests/reference/{proj/4_nwProject.nwx => coreProject_4_nwProject.nwx} (100%) rename tests/reference/{proj/5_nwProject.nwx => coreProject_5_nwProject.nwx} (100%) rename tests/{test_project.py => test_core_project.py} (82%) diff --git a/pytest.ini b/pytest.ini index 0c3af3ba..b61d59ec 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,5 @@ [pytest] markers = - project: Project classes tests error: Test various error handling scenarios core: Core functionality tests gui: Qt5 GUI tests diff --git a/tests/README.md b/tests/README.md index b9c221f9..82a34712 100644 --- a/tests/README.md +++ b/tests/README.md @@ -69,4 +69,5 @@ The commands for the respective test categories are listed below. | 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 3a97c58f..e6b90511 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -51,6 +51,20 @@ def outDir(tmpDir): os.mkdir(theDir) return theDir +@pytest.fixture(scope="function") +def fncDir(tmpDir): + """A temporary folder for a single test function. + """ + funcDir = os.path.join(tmpDir, "ftemp") + if os.path.isdir(funcDir): + shutil.rmtree(funcDir) + if not os.path.isdir(funcDir): + os.mkdir(funcDir) + yield funcDir + if os.path.isdir(funcDir): + shutil.rmtree(funcDir) + return + ## # novelWriter Objects ## @@ -158,32 +172,10 @@ def yesToAll(monkeypatch): # =============================================================================================== # -## -# novelWriter Objects -## - -@pytest.fixture(scope="session") -def nwConf(refDir, tmpDir): - """Temporary novelWriter configuration used for the dummy instance - of novelWriter's main GUI. - """ - theConf = Config() - theConf.initConfig(refDir, tmpDir) - return theConf - ## # Temporary Test Folders ## -@pytest.fixture(scope="session") -def nwTempProj(tmpDir): - """A temporary folder for project tests. - """ - projDir = os.path.join(tmpDir, "proj") - if not os.path.isdir(projDir): - os.mkdir(projDir) - return projDir - @pytest.fixture(scope="session") def nwTempGUI(tmpDir): """A temporary folder for GUI tests. diff --git a/tests/reference/proj/1_nwProject.nwx b/tests/reference/coreProject_1_nwProject.nwx similarity index 100% rename from tests/reference/proj/1_nwProject.nwx rename to tests/reference/coreProject_1_nwProject.nwx diff --git a/tests/reference/proj/2_nwProject.nwx b/tests/reference/coreProject_2_nwProject.nwx similarity index 100% rename from tests/reference/proj/2_nwProject.nwx rename to tests/reference/coreProject_2_nwProject.nwx diff --git a/tests/reference/proj/3_nwProject.nwx b/tests/reference/coreProject_3_nwProject.nwx similarity index 100% rename from tests/reference/proj/3_nwProject.nwx rename to tests/reference/coreProject_3_nwProject.nwx diff --git a/tests/reference/proj/4_nwProject.nwx b/tests/reference/coreProject_4_nwProject.nwx similarity index 100% rename from tests/reference/proj/4_nwProject.nwx rename to tests/reference/coreProject_4_nwProject.nwx diff --git a/tests/reference/proj/5_nwProject.nwx b/tests/reference/coreProject_5_nwProject.nwx similarity index 100% rename from tests/reference/proj/5_nwProject.nwx rename to tests/reference/coreProject_5_nwProject.nwx diff --git a/tests/test_project.py b/tests/test_core_project.py similarity index 82% rename from tests/test_project.py rename to tests/test_core_project.py index 748021a7..6cf20033 100644 --- a/tests/test_project.py +++ b/tests/test_core_project.py @@ -13,13 +13,13 @@ from tools import cmpFiles from nw.core.project import NWProject from nw.constants import nwItemClass, nwItemType, nwItemLayout, nwFiles -@pytest.mark.project -def testProjectNewOpenSave(nwFuncTemp, nwTempProj, refDir, tmpDir, dummyGUI): - """Test that a basic project can be created, and opened and saved. +@pytest.mark.core +def testCoreProject_NewOpenSave(fncDir, outDir, refDir, tmpDir, dummyGUI): + """Test that a basic project can be created, opened and saved. """ - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "1_nwProject.nwx") - refFile = os.path.join(refDir, "proj", "1_nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_1_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_1_nwProject.nwx") theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) @@ -28,17 +28,17 @@ def testProjectNewOpenSave(nwFuncTemp, nwTempProj, refDir, tmpDir, dummyGUI): assert not theProject.newProject({}) # Try again with a proper path - assert theProject.newProject({"projPath": nwFuncTemp}) - assert theProject.setProjectPath(nwFuncTemp) + assert theProject.newProject({"projPath": fncDir}) + assert theProject.setProjectPath(fncDir) assert theProject.saveProject() assert theProject.closeProject() # Creating the project once more should fail - assert not theProject.newProject({"projPath": nwFuncTemp}) + assert not theProject.newProject({"projPath": fncDir}) # Check the new project copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) # Open again assert theProject.openProject(projFile) @@ -47,7 +47,7 @@ def testProjectNewOpenSave(nwFuncTemp, nwTempProj, refDir, tmpDir, dummyGUI): assert theProject.saveProject() assert theProject.closeProject() copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) assert not theProject.projChanged # Open a second time @@ -57,21 +57,23 @@ def testProjectNewOpenSave(nwFuncTemp, nwTempProj, refDir, tmpDir, dummyGUI): assert theProject.saveProject() assert theProject.closeProject() copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) -@pytest.mark.project -def testProjectNewRoot(nwFuncTemp, nwTempProj, refDir, dummyGUI): +# END Test testCoreProject_NewOpenSave + +@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(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "2_nwProject.nwx") - refFile = os.path.join(refDir, "proj", "2_nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_2_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_2_nwProject.nwx") theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) - assert theProject.newProject({"projPath": nwFuncTemp}) - assert theProject.setProjectPath(nwFuncTemp) + assert theProject.newProject({"projPath": fncDir}) + assert theProject.setProjectPath(fncDir) assert theProject.saveProject() assert theProject.closeProject() assert theProject.openProject(projFile) @@ -90,22 +92,24 @@ def testProjectNewRoot(nwFuncTemp, nwTempProj, refDir, dummyGUI): assert theProject.closeProject() copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) assert not theProject.projChanged -@pytest.mark.project -def testProjectNewFile(nwFuncTemp, nwTempProj, refDir, dummyGUI): +# 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(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "3_nwProject.nwx") - refFile = os.path.join(refDir, "proj", "3_nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_3_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_3_nwProject.nwx") theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) - assert theProject.newProject({"projPath": nwFuncTemp}) - assert theProject.setProjectPath(nwFuncTemp) + assert theProject.newProject({"projPath": fncDir}) + assert theProject.setProjectPath(fncDir) assert theProject.saveProject() assert theProject.closeProject() assert theProject.openProject(projFile) @@ -117,23 +121,25 @@ def testProjectNewFile(nwFuncTemp, nwTempProj, refDir, dummyGUI): assert theProject.closeProject() copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) assert not theProject.projChanged -@pytest.mark.project -def testProjectNewCustomA(nwFuncTemp, nwTempProj, refDir, dummyGUI): +# END Test testCoreProject_NewFile + +@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(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "4_nwProject.nwx") - refFile = os.path.join(refDir, "proj", "4_nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_4_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_4_nwProject.nwx") projData = { "projName": "Test Custom", "projTitle": "Test Novel", "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": nwFuncTemp, + "projPath": fncDir, "popSample": False, "popMinimal": False, "popCustom": True, @@ -157,22 +163,24 @@ def testProjectNewCustomA(nwFuncTemp, nwTempProj, refDir, dummyGUI): assert theProject.closeProject() copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) -@pytest.mark.project -def testProjectNewCustomB(nwFuncTemp, nwTempProj, refDir, dummyGUI): +# 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(nwFuncTemp, "nwProject.nwx") - testFile = os.path.join(nwTempProj, "5_nwProject.nwx") - refFile = os.path.join(refDir, "proj", "5_nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_5_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_5_nwProject.nwx") projData = { "projName": "Test Custom", "projTitle": "Test Novel", "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": nwFuncTemp, + "projPath": fncDir, "popSample": False, "popMinimal": False, "popCustom": True, @@ -196,10 +204,12 @@ def testProjectNewCustomB(nwFuncTemp, nwTempProj, refDir, dummyGUI): assert theProject.closeProject() copyfile(projFile, testFile) - assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) + assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) -@pytest.mark.project -def testProjectNewSampleA(nwFuncTemp, nwConf, dummyGUI, tmpDir): +# 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. """ @@ -207,22 +217,22 @@ def testProjectNewSampleA(nwFuncTemp, nwConf, dummyGUI, tmpDir): "projName": "Test Sample", "projTitle": "Test Novel", "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": nwFuncTemp, + "projPath": fncDir, "popSample": True, "popMinimal": False, "popCustom": False, } theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) - theProject.mainConf = nwConf + 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(nwConf.appRoot, "sample")) + srcSample = os.path.abspath(os.path.join(tmpConf.appRoot, "sample")) dstSample = os.path.join(tmpDir, "sample.zip") - nwConf.assetPath = tmpDir + tmpConf.assetPath = tmpDir # Create and open a defective zip file with open(dstSample, mode="w+") as outFile: @@ -239,14 +249,16 @@ def testProjectNewSampleA(nwFuncTemp, nwConf, dummyGUI, tmpDir): zipObj.write(srcDoc, "content/"+docFile) assert theProject.newProject(projData) - assert theProject.openProject(nwFuncTemp) + assert theProject.openProject(fncDir) assert theProject.projName == "Sample Project" assert theProject.saveProject() assert theProject.closeProject() os.unlink(dstSample) -@pytest.mark.project -def testProjectNewSampleB(monkeypatch, nwFuncTemp, nwConf, dummyGUI, tmpDir): +# 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. """ @@ -254,17 +266,17 @@ def testProjectNewSampleB(monkeypatch, nwFuncTemp, nwConf, dummyGUI, tmpDir): "projName": "Test Sample", "projTitle": "Test Novel", "projAuthors": "Jane Doe\nJohn Doh\n", - "projPath": nwFuncTemp, + "projPath": fncDir, "popSample": True, "popMinimal": False, "popCustom": False, } theProject = NWProject(dummyGUI) theProject.projTree.setSeed(42) - theProject.mainConf = nwConf + theProject.mainConf = tmpConf # Make sure we do not pick up the nw/assets/sample.zip file - nwConf.assetPath = tmpDir + tmpConf.assetPath = tmpDir # Set a fake project file name monkeypatch.setattr(nwFiles, "PROJ_FILE", "nothing.nwx") @@ -272,17 +284,19 @@ def testProjectNewSampleB(monkeypatch, nwFuncTemp, nwConf, dummyGUI, tmpDir): monkeypatch.setattr(nwFiles, "PROJ_FILE", "nwProject.nwx") assert theProject.newProject(projData) - assert theProject.openProject(nwFuncTemp) + assert theProject.openProject(fncDir) assert theProject.projName == "Sample Project" assert theProject.saveProject() assert theProject.closeProject() # Misdirect the appRoot path so neither is possible - nwConf.appRoot = tmpDir + tmpConf.appRoot = tmpDir assert not theProject.newProject(projData) -@pytest.mark.project -def testProjectMethods(monkeypatch, nwMinimal, dummyGUI): +# END Test testCoreProject_NewSampleB + +@pytest.mark.core +def testCoreProject_Methods(monkeypatch, nwMinimal, dummyGUI): """Test other project class methods and functions. """ theProject = NWProject(dummyGUI) @@ -323,8 +337,10 @@ def testProjectMethods(monkeypatch, nwMinimal, dummyGUI): assert theProject.setBookAuthors(" Jane Doe \n John Doh \n ") assert theProject.bookAuthors == ["Jane Doe", "John Doh"] -@pytest.mark.project -def testProjectOrphanedFiles(dummyGUI, nwLipsum): +# 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 @@ -392,8 +408,10 @@ def testProjectOrphanedFiles(dummyGUI, nwLipsum): assert theProject.saveProject(nwLipsum) assert theProject.closeProject() -@pytest.mark.project -def testProjectOldFormat(dummyGUI, nwOldProj): +# 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 @@ -482,8 +500,10 @@ def testProjectOldFormat(dummyGUI, nwOldProj): assert os.path.isfile(os.path.join(nwOldProj, "meta", "sessionStats.log")) assert os.path.isfile(os.path.join(nwOldProj, "ToC.txt")) -@pytest.mark.project -def testProjectBackup(dummyGUI, nwMinimal, tmpDir): +# END Test testCoreProject_OldFormat + +@pytest.mark.core +def testCoreProject_Backup(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 @@ -530,3 +550,5 @@ def testProjectBackup(dummyGUI, nwMinimal, tmpDir): assert cmpFiles( os.path.join(nwMinimal, "nwProject.nwx"), os.path.join(tmpDir, "extract", "nwProject.nwx") ) + +# END Test testCoreProject_Backup From c17d4de0a5984be468006d7e2dec2bfae5789af0 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 5 Dec 2020 19:13:36 +0100 Subject: [PATCH 29/52] Remove QMessageBox dependency in NWProject class --- nw/core/project.py | 86 +++++++++++++++++++++------------------------- nw/core/status.py | 3 +- nw/guimain.py | 7 ++++ 3 files changed, 49 insertions(+), 47 deletions(-) diff --git a/nw/core/project.py b/nw/core/project.py index f8e45194..b646934a 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 @@ -1292,7 +1285,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 +1345,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. @@ -1490,7 +1483,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/status.py b/nw/core/status.py index 2c6fe811..ccec2e73 100644 --- a/nw/core/status.py +++ b/nw/core/status.py @@ -109,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: diff --git a/nw/guimain.py b/nw/guimain.py index 76b344fb..9efc9a2e 100644 --- a/nw/guimain.py +++ b/nw/guimain.py @@ -982,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 From c353e9ffb121196fdfffaee270454296a799073b Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 5 Dec 2020 21:06:57 +0100 Subject: [PATCH 30/52] Updated and new tests for NWProject class --- nw/core/project.py | 51 +- tests/README.md | 1 + tests/conftest.py | 9 +- tests/dummy.py | 20 + tests/reference/coreProject_2_nwProject.nwx | 189 ++++- tests/reference/coreProject_3_nwProject.nwx | 108 ++- tests/reference/coreProject_4_nwProject.nwx | 189 +---- tests/reference/coreProject_5_nwProject.nwx | 108 +-- tests/test_core_project.py | 832 ++++++++++++++++++-- tests/test_core_tree.py | 6 +- 10 files changed, 1101 insertions(+), 412 deletions(-) diff --git a/nw/core/project.py b/nw/core/project.py index b646934a..1a834d73 100644 --- a/nw/core/project.py +++ b/nw/core/project.py @@ -1125,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 @@ -1147,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) @@ -1189,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: @@ -1203,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. @@ -1236,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. @@ -1356,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 diff --git a/tests/README.md b/tests/README.md index 82a34712..cd1be55b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -65,6 +65,7 @@ The commands for the respective test categories are listed below. | 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` | diff --git a/tests/conftest.py b/tests/conftest.py index e6b90511..1f7b88ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,12 +6,15 @@ import sys import pytest import shutil import os +import time from dummy import DummyMain from PyQt5.QtWidgets import QMessageBox sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir))) +os.environ["TZ"] = "UTC" +time.tzset() from nw.config import Config # noqa: E402 @@ -69,7 +72,7 @@ def fncDir(tmpDir): # novelWriter Objects ## -@pytest.fixture(scope="session") +@pytest.fixture(scope="function") def tmpConf(tmpDir): """Create a temporary novelWriter configuration object. """ @@ -78,7 +81,7 @@ def tmpConf(tmpDir): theConf.setLastPath("") return theConf -@pytest.fixture(scope="session") +@pytest.fixture(scope="function") def dummyGUI(tmpConf): """Create a dummy instance of novelWriter's main GUI class. """ @@ -168,6 +171,8 @@ def yesToAll(monkeypatch): monkeypatch.setattr( QMessageBox, "critical", lambda *args, **kwargs: QMessageBox.Yes ) + yield + monkeypatch.undo() return # =============================================================================================== # diff --git a/tests/dummy.py b/tests/dummy.py index 803e4e12..0f26e0ff 100644 --- a/tests/dummy.py +++ b/tests/dummy.py @@ -14,12 +14,22 @@ class DummyMain(): self.theIndex = None self.theProject = None self.statusBar = StatusBar() + + # 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 @@ -32,6 +42,16 @@ class DummyMain(): def rebuildIndex(self): return + # Test Functions + + def undo(self): + self.askResponse = True + return + + def clear(self): + self.lastAlert = "" + return + # END Class GuiMain class StatusBar(): diff --git a/tests/reference/coreProject_2_nwProject.nwx b/tests/reference/coreProject_2_nwProject.nwx index 9ea0617f..fc7e7ab2 100644 --- a/tests/reference/coreProject_2_nwProject.nwx +++ b/tests/reference/coreProject_2_nwProject.nwx @@ -1,9 +1,11 @@ - New Project - - 2 + Test Custom + Test Novel + Jane Doe + John Doh + 1 1 0 @@ -38,7 +40,7 @@ Main - + Novel ROOT @@ -61,13 +63,34 @@ False - World + Locations ROOT WORLD New False - + + Timeline + ROOT + TIMELINE + New + False + + + Objects + ROOT + OBJECT + New + False + + + Entity + ROOT + ENTITY + New + False + + Title Page FILE NOVEL @@ -79,15 +102,15 @@ 0 0 - - New Chapter + + Chapter 1 FOLDER NOVEL New False - - New Chapter + + Chapter 1 FILE NOVEL New @@ -98,8 +121,8 @@ 0 0 - - New Scene + + Scene 1.1 FILE NOVEL New @@ -110,33 +133,139 @@ 0 0 - - Timeline - ROOT - TIMELINE + + Scene 1.2 + FILE + NOVEL + New + True + SCENE + 0 + 0 + 0 + 0 + + + Scene 1.3 + FILE + NOVEL + New + True + SCENE + 0 + 0 + 0 + 0 + + + Chapter 2 + FOLDER + NOVEL New False - - Object - ROOT - OBJECT + + Chapter 2 + FILE + NOVEL + New + True + CHAPTER + 0 + 0 + 0 + 0 + + + Scene 2.1 + FILE + NOVEL + New + True + SCENE + 0 + 0 + 0 + 0 + + + Scene 2.2 + FILE + NOVEL + New + True + SCENE + 0 + 0 + 0 + 0 + + + Scene 2.3 + FILE + NOVEL + New + True + SCENE + 0 + 0 + 0 + 0 + + + Chapter 3 + FOLDER + NOVEL New False - - Custom1 - ROOT - CUSTOM + + Chapter 3 + FILE + NOVEL New - False + True + CHAPTER + 0 + 0 + 0 + 0 - - Custom2 - ROOT - CUSTOM + + Scene 3.1 + FILE + NOVEL New - False + True + SCENE + 0 + 0 + 0 + 0 + + + Scene 3.2 + FILE + NOVEL + New + True + SCENE + 0 + 0 + 0 + 0 + + + Scene 3.3 + FILE + NOVEL + New + True + SCENE + 0 + 0 + 0 + 0 diff --git a/tests/reference/coreProject_3_nwProject.nwx b/tests/reference/coreProject_3_nwProject.nwx index 66488434..11d007aa 100644 --- a/tests/reference/coreProject_3_nwProject.nwx +++ b/tests/reference/coreProject_3_nwProject.nwx @@ -1,9 +1,11 @@ - + - New Project - - 2 + Test Custom + Test Novel + Jane Doe + John Doh + 1 1 0 @@ -38,7 +40,7 @@ Main - + Novel ROOT @@ -61,13 +63,34 @@ False - World + Locations ROOT WORLD New False - + + Timeline + ROOT + TIMELINE + New + False + + + Objects + ROOT + OBJECT + New + False + + + Entity + ROOT + ENTITY + New + False + + Title Page FILE NOVEL @@ -79,27 +102,8 @@ 0 0 - - New Chapter - FOLDER - NOVEL - New - False - - - New Chapter - FILE - NOVEL - New - True - CHAPTER - 0 - 0 - 0 - 0 - - - New Scene + + Scene 1 FILE NOVEL New @@ -110,8 +114,8 @@ 0 0 - - Hello + + Scene 2 FILE NOVEL New @@ -122,13 +126,49 @@ 0 0 - - Jane + + Scene 3 FILE - CHARACTER + NOVEL New True - NOTE + SCENE + 0 + 0 + 0 + 0 + + + Scene 4 + FILE + NOVEL + New + True + SCENE + 0 + 0 + 0 + 0 + + + Scene 5 + FILE + NOVEL + New + True + SCENE + 0 + 0 + 0 + 0 + + + Scene 6 + FILE + NOVEL + New + True + SCENE 0 0 0 diff --git a/tests/reference/coreProject_4_nwProject.nwx b/tests/reference/coreProject_4_nwProject.nwx index fc7e7ab2..9ea0617f 100644 --- a/tests/reference/coreProject_4_nwProject.nwx +++ b/tests/reference/coreProject_4_nwProject.nwx @@ -1,11 +1,9 @@ - Test Custom - Test Novel - Jane Doe - John Doh - 1 + New Project + + 2 1 0 @@ -40,7 +38,7 @@ Main - + Novel ROOT @@ -63,34 +61,13 @@ False - Locations + World ROOT WORLD New False - - Timeline - ROOT - TIMELINE - New - False - - - Objects - ROOT - OBJECT - New - False - - - Entity - ROOT - ENTITY - New - False - - + Title Page FILE NOVEL @@ -102,15 +79,15 @@ 0 0 - - Chapter 1 + + New Chapter FOLDER NOVEL New False - - Chapter 1 + + New Chapter FILE NOVEL New @@ -121,8 +98,8 @@ 0 0 - - Scene 1.1 + + New Scene FILE NOVEL New @@ -133,139 +110,33 @@ 0 0 - - Scene 1.2 - FILE - NOVEL - New - True - SCENE - 0 - 0 - 0 - 0 - - - Scene 1.3 - FILE - NOVEL - New - True - SCENE - 0 - 0 - 0 - 0 - - - Chapter 2 - FOLDER - NOVEL + + Timeline + ROOT + TIMELINE New False - - Chapter 2 - FILE - NOVEL - New - True - CHAPTER - 0 - 0 - 0 - 0 - - - Scene 2.1 - FILE - NOVEL - New - True - SCENE - 0 - 0 - 0 - 0 - - - Scene 2.2 - FILE - NOVEL - New - True - SCENE - 0 - 0 - 0 - 0 - - - Scene 2.3 - FILE - NOVEL - New - True - SCENE - 0 - 0 - 0 - 0 - - - Chapter 3 - FOLDER - NOVEL + + Object + ROOT + OBJECT New False - - Chapter 3 - FILE - NOVEL + + Custom1 + ROOT + CUSTOM New - True - CHAPTER - 0 - 0 - 0 - 0 + False - - Scene 3.1 - FILE - NOVEL + + Custom2 + ROOT + CUSTOM New - True - SCENE - 0 - 0 - 0 - 0 - - - Scene 3.2 - FILE - NOVEL - New - True - SCENE - 0 - 0 - 0 - 0 - - - Scene 3.3 - FILE - NOVEL - New - True - SCENE - 0 - 0 - 0 - 0 + False diff --git a/tests/reference/coreProject_5_nwProject.nwx b/tests/reference/coreProject_5_nwProject.nwx index 11d007aa..66488434 100644 --- a/tests/reference/coreProject_5_nwProject.nwx +++ b/tests/reference/coreProject_5_nwProject.nwx @@ -1,11 +1,9 @@ - + - Test Custom - Test Novel - Jane Doe - John Doh - 1 + New Project + + 2 1 0 @@ -40,7 +38,7 @@ Main - + Novel ROOT @@ -63,34 +61,13 @@ False - Locations + World ROOT WORLD New False - - Timeline - ROOT - TIMELINE - New - False - - - Objects - ROOT - OBJECT - New - False - - - Entity - ROOT - ENTITY - New - False - - + Title Page FILE NOVEL @@ -102,8 +79,27 @@ 0 0 - - Scene 1 + + New Chapter + FOLDER + NOVEL + New + False + + + New Chapter + FILE + NOVEL + New + True + CHAPTER + 0 + 0 + 0 + 0 + + + New Scene FILE NOVEL New @@ -114,8 +110,8 @@ 0 0 - - Scene 2 + + Hello FILE NOVEL New @@ -126,49 +122,13 @@ 0 0 - - Scene 3 + + Jane FILE - NOVEL + CHARACTER New True - SCENE - 0 - 0 - 0 - 0 - - - Scene 4 - FILE - NOVEL - New - True - SCENE - 0 - 0 - 0 - 0 - - - Scene 5 - FILE - NOVEL - New - True - SCENE - 0 - 0 - 0 - 0 - - - Scene 6 - FILE - NOVEL - New - True - SCENE + NOTE 0 0 0 diff --git a/tests/test_core_project.py b/tests/test_core_project.py index 6cf20033..869603fe 100644 --- a/tests/test_core_project.py +++ b/tests/test_core_project.py @@ -7,15 +7,18 @@ import os from shutil import copyfile from zipfile import ZipFile +from lxml import etree -from tools import cmpFiles +from tools import cmpFiles, writeFile, readFile +from dummy import causeOSError from nw.core.project import NWProject from nw.constants import nwItemClass, nwItemType, nwItemLayout, nwFiles @pytest.mark.core -def testCoreProject_NewOpenSave(fncDir, outDir, refDir, tmpDir, dummyGUI): - """Test that a basic project can be created, opened and saved. +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_1_nwProject.nwx") @@ -29,7 +32,6 @@ def testCoreProject_NewOpenSave(fncDir, outDir, refDir, tmpDir, dummyGUI): # Try again with a proper path assert theProject.newProject({"projPath": fncDir}) - assert theProject.setProjectPath(fncDir) assert theProject.saveProject() assert theProject.closeProject() @@ -59,72 +61,7 @@ def testCoreProject_NewOpenSave(fncDir, outDir, refDir, tmpDir, dummyGUI): copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, [2, 6, 7, 8]) -# END Test testCoreProject_NewOpenSave - -@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_2_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_2_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_3_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_3_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 +# END Test testCoreProject_NewMinimal @pytest.mark.core def testCoreProject_NewCustomA(fncDir, outDir, refDir, dummyGUI): @@ -132,8 +69,8 @@ def testCoreProject_NewCustomA(fncDir, outDir, refDir, dummyGUI): Custom type with chapters and scenes. """ projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_4_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_4_nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_2_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_2_nwProject.nwx") projData = { "projName": "Test Custom", @@ -173,8 +110,8 @@ def testCoreProject_NewCustomB(fncDir, outDir, refDir, dummyGUI): Custom type without chapters, but with scenes. """ projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_5_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_5_nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_3_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_3_nwProject.nwx") projData = { "projName": "Test Custom", @@ -296,7 +233,418 @@ def testCoreProject_NewSampleB(monkeypatch, fncDir, tmpConf, dummyGUI, tmpDir): # END Test testCoreProject_NewSampleB @pytest.mark.core -def testCoreProject_Methods(monkeypatch, nwMinimal, dummyGUI): +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_4_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_4_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_5_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_5_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) @@ -317,13 +665,13 @@ def testCoreProject_Methods(monkeypatch, nwMinimal, dummyGUI): assert theProject.setProjectPath(projPath, newProject=True) # Make os.mkdir fail - def altMkdir(*args): - raise Exception("Oops!") - - monkeypatch.setattr("os.mkdir", altMkdir) + 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" @@ -337,6 +685,191 @@ def testCoreProject_Methods(monkeypatch, nwMinimal, dummyGUI): 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" + "2020-09-13 13:00:00 2020-09-13 14:00:00 200 100\n" + ) + + # 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 @@ -347,6 +880,7 @@ def testCoreProject_OrphanedFiles(dummyGUI, nwLipsum): 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() @@ -408,6 +942,10 @@ def testCoreProject_OrphanedFiles(dummyGUI, nwLipsum): 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 @@ -418,7 +956,6 @@ def testCoreProject_OldFormat(dummyGUI, nwOldProj): contained in a single 'content' folder. """ theProject = NWProject(dummyGUI) - theProject.mainConf.showGUI = False # Create dummy files for known legacy files deleteFiles = [ @@ -451,8 +988,7 @@ def testCoreProject_OldFormat(dummyGUI, nwOldProj): # Create dummy files os.mkdir(os.path.join(nwOldProj, "cache")) for aFile in deleteFiles: - with open(aFile, mode="w+", encoding="utf8") as outFile: - outFile.write("Hi") + writeFile(aFile, "Hi") for aFile in deleteFiles: assert os.path.isfile(aFile) @@ -503,7 +1039,112 @@ def testCoreProject_OldFormat(dummyGUI, nwOldProj): # END Test testCoreProject_OldFormat @pytest.mark.core -def testCoreProject_Backup(dummyGUI, nwMinimal, tmpDir): +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 @@ -513,6 +1154,12 @@ def testCoreProject_Backup(dummyGUI, nwMinimal, tmpDir): 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) @@ -531,9 +1178,21 @@ def testCoreProject_Backup(dummyGUI, nwMinimal, tmpDir): theProject.mainConf.backupPath = nwMinimal assert not theProject.zipIt(doNotify=False) - # Test correct settings + # Set a valid folder theProject.mainConf.backupPath = tmpDir - assert theProject.zipIt(doNotify=False) + + # 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 @@ -548,7 +1207,8 @@ def testCoreProject_Backup(dummyGUI, nwMinimal, tmpDir): # Check that the main project file was restored assert cmpFiles( - os.path.join(nwMinimal, "nwProject.nwx"), os.path.join(tmpDir, "extract", "nwProject.nwx") + os.path.join(nwMinimal, "nwProject.nwx"), + os.path.join(tmpDir, "extract", "nwProject.nwx") ) # END Test testCoreProject_Backup diff --git a/tests/test_core_tree.py b/tests/test_core_tree.py index f417fd6a..29c1ca95 100644 --- a/tests/test_core_tree.py +++ b/tests/test_core_tree.py @@ -10,7 +10,7 @@ from lxml import etree from nw.core.project import NWProject, NWItem, NWTree from nw.constants import nwItemClass, nwItemType, nwItemLayout, nwFiles -@pytest.fixture(scope="session") +@pytest.fixture(scope="function") def dummyItems(dummyGUI): """Create a list of dummy items. """ @@ -353,7 +353,7 @@ def testCoreTree_XMLPackUnpack(dummyGUI, dummyItems): b"True" b"" b"Chapter OneFILENOVELNone" - b"TrueUNNUMBERED300" + b"TrueCHAPTER300" b"5020" b"" b"Scene OneFILENOVELNone" @@ -425,7 +425,7 @@ def testCoreTree_ToCFile(monkeypatch, dummyGUI, dummyItems, tmpDir): "\n" "File Name Class Layout Document Label\n" "-------------------------------------------------------------\n" - f"{pathA} NOVEL UNNUMBERED Chapter One\n" + f"{pathA} NOVEL CHAPTER Chapter One\n" f"{pathB} NOVEL SCENE Scene One\n" f"{pathC} CHARACTER NOTE Jane Doe\n" ) From d274c418d141d51e1cbb45248c39963ce79dca77 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 5 Dec 2020 21:08:42 +0100 Subject: [PATCH 31/52] Remove nwFuncTemp fixture --- tests/conftest.py | 14 ------------- tests/test_dialogs.py | 40 ++++++++++++++++++------------------- tests/test_error.py | 4 ++-- tests/test_gui.py | 46 +++++++++++++++++++++---------------------- 4 files changed, 45 insertions(+), 59 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1f7b88ae..5195b841 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -198,17 +198,3 @@ def nwTempBuild(tmpDir): if not os.path.isdir(buildDir): os.mkdir(buildDir) return buildDir - -@pytest.fixture(scope="function") -def nwFuncTemp(tmpDir): - """A temporary folder for a single test function. - """ - funcDir = os.path.join(tmpDir, "ftemp") - if os.path.isdir(funcDir): - shutil.rmtree(funcDir) - if not os.path.isdir(funcDir): - os.mkdir(funcDir) - yield funcDir - if os.path.isdir(funcDir): - shutil.rmtree(funcDir) - return diff --git a/tests/test_dialogs.py b/tests/test_dialogs.py index b8e3b276..96f64da4 100644 --- a/tests/test_dialogs.py +++ b/tests/test_dialogs.py @@ -30,7 +30,7 @@ typeDelay = 1 stepDelay = 20 @pytest.mark.gui -def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, refDir, tmpDir): +def testProjectSettings(qtbot, monkeypatch, yesToAll, fncDir, nwTempGUI, refDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() @@ -43,8 +43,8 @@ def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, ref # Create new project nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) - nwGUI.mainConf.backupPath = nwFuncTemp + assert nwGUI.newProject({"projPath": fncDir}) + nwGUI.mainConf.backupPath = fncDir # Get the dialog object monkeypatch.setattr(GuiProjectSettings, "exec_", lambda *args: None) @@ -135,7 +135,7 @@ def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, ref qtbot.wait(stepDelay) # Check the files - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "2_nwProject.nwx") refFile = os.path.join(refDir, "gui", "2_nwProject.nwx") copyfile(projFile, testFile) @@ -145,7 +145,7 @@ def testProjectSettings(qtbot, monkeypatch, yesToAll, nwFuncTemp, nwTempGUI, ref nwGUI.closeMain() @pytest.mark.gui -def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, refDir, tmpDir): +def testItemEditor(qtbot, yesToAll, monkeypatch, fncDir, nwTempGUI, refDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() @@ -154,7 +154,7 @@ def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, refDir, # Create new, save, open project nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) + assert nwGUI.newProject({"projPath": fncDir}) assert nwGUI.openDocument("0e17daca5f3e1") assert nwGUI.treeView.setSelectedHandle("0e17daca5f3e1", doScroll=True) @@ -208,7 +208,7 @@ def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, refDir, qtbot.wait(stepDelay) # Check the files - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "3_nwProject.nwx") refFile = os.path.join(refDir, "gui", "3_nwProject.nwx") copyfile(projFile, testFile) @@ -218,7 +218,7 @@ def testItemEditor(qtbot, yesToAll, monkeypatch, nwFuncTemp, nwTempGUI, refDir, nwGUI.closeMain() @pytest.mark.gui -def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): +def testWritingStatsExport(qtbot, monkeypatch, yesToAll, fncDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() @@ -227,13 +227,13 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): # Create new, save, close project nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) + assert nwGUI.newProject({"projPath": fncDir}) qtbot.wait(200) assert nwGUI.saveProject() assert nwGUI.closeProject() qtbot.wait(stepDelay) - sessFile = os.path.join(nwFuncTemp, "meta", nwFiles.SESS_STATS) + sessFile = os.path.join(fncDir, "meta", nwFiles.SESS_STATS) with open(sessFile, mode="w+", encoding="utf-8") as outFile: outFile.write( "# Start Time End Time Novel Notes\n" @@ -244,10 +244,10 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): ) # Open again, and check the stats - assert nwGUI.openProject(nwFuncTemp) + assert nwGUI.openProject(fncDir) qtbot.wait(stepDelay) - nwGUI.mainConf.lastPath = nwFuncTemp + nwGUI.mainConf.lastPath = fncDir nwGUI.mainMenu.aWritingStats.activate(QAction.Trigger) qtbot.waitUntil(lambda: getGuiItem("GuiWritingStats") is not None, timeout=1000) @@ -264,7 +264,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(100) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -282,7 +282,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.loads(inFile.read()) @@ -299,7 +299,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -316,7 +316,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -329,7 +329,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -341,7 +341,7 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): assert sessLog._saveData(sessLog.FMT_JSON) qtbot.wait(stepDelay) - jsonStats = os.path.join(nwFuncTemp, "sessionStats.json") + jsonStats = os.path.join(fncDir, "sessionStats.json") with open(jsonStats, mode="r", encoding="utf-8") as inFile: jsonData = json.load(inFile) @@ -357,8 +357,8 @@ def testWritingStatsExport(qtbot, monkeypatch, yesToAll, nwFuncTemp, tmpDir): nwGUI.closeMain() @pytest.mark.gui -def testAboutBox(qtbot, monkeypatch, nwFuncTemp, tmpDir): - nwGUI = nw.main(["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir]) +def testAboutBox(qtbot, monkeypatch, fncDir, tmpDir): + nwGUI = nw.main(["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) diff --git a/tests/test_error.py b/tests/test_error.py index 7ad07269..192fb6cb 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -10,9 +10,9 @@ from PyQt5.QtWidgets import qApp from nw.error import NWErrorMessage, exceptionHandler @pytest.mark.error -def testErrorDialog(qtbot, nwFuncTemp, tmpDir): +def testErrorDialog(qtbot, fncDir, tmpDir): qApp.closeAllWindows() - nwGUI = nw.main(["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir]) + nwGUI = nw.main(["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() qtbot.waitForWindowShown(nwGUI) diff --git a/tests/test_gui.py b/tests/test_gui.py index a6438d0a..a10c0582 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -27,11 +27,11 @@ typeDelay = 1 stepDelay = 20 @pytest.mark.gui -def testLaunch(qtbot, monkeypatch, nwFuncTemp, tmpDir): +def testLaunch(qtbot, monkeypatch, fncDir, tmpDir): # Defaults nwGUI = nw.main( - ["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir, "--style=Fusion"] + ["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir, "--style=Fusion"] ) assert nw.logger.getEffectiveLevel() == logging.WARNING nwGUI.closeMain() @@ -39,21 +39,21 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, tmpDir): # Log Levels nwGUI = nw.main( - ["--testmode", "--info", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] + ["--testmode", "--info", "--config=%s" % fncDir, "--data=%s" % tmpDir] ) assert nw.logger.getEffectiveLevel() == logging.INFO nwGUI.closeMain() nwGUI.close() nwGUI = nw.main( - ["--testmode", "--debug", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] + ["--testmode", "--debug", "--config=%s" % fncDir, "--data=%s" % tmpDir] ) assert nw.logger.getEffectiveLevel() == logging.DEBUG nwGUI.closeMain() nwGUI.close() nwGUI = nw.main( - ["--testmode", "--verbose", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] + ["--testmode", "--verbose", "--config=%s" % fncDir, "--data=%s" % tmpDir] ) assert nw.logger.getEffectiveLevel() == 5 nwGUI.closeMain() @@ -62,7 +62,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, tmpDir): # Help and Version with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--help", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] + ["--testmode", "--help", "--config=%s" % fncDir, "--data=%s" % tmpDir] ) nwGUI.closeMain() nwGUI.close() @@ -70,7 +70,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, tmpDir): with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--version", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] + ["--testmode", "--version", "--config=%s" % fncDir, "--data=%s" % tmpDir] ) nwGUI.closeMain() nwGUI.close() @@ -79,7 +79,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, tmpDir): # Invalid options with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--invalid", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] + ["--testmode", "--invalid", "--config=%s" % fncDir, "--data=%s" % tmpDir] ) nwGUI.closeMain() nwGUI.close() @@ -92,7 +92,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, tmpDir): monkeypatch.setattr("nw.CONFIG.verPyQtValue", 50000) with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--config=%s" % nwFuncTemp, "--data=%s" % tmpDir] + ["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir] ) nwGUI.closeMain() nwGUI.close() @@ -103,7 +103,7 @@ def testLaunch(qtbot, monkeypatch, nwFuncTemp, tmpDir): monkeypatch.undo() @pytest.mark.gui -def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, refDir, tmpDir): +def testDocEditor(qtbot, yesToAll, fncDir, nwTempGUI, refDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) @@ -113,7 +113,7 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, refDir, tmpDir): # Create new, save, close project nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) + assert nwGUI.newProject({"projPath": fncDir}) assert nwGUI.saveProject() assert nwGUI.closeProject() @@ -130,7 +130,7 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, refDir, tmpDir): assert not nwGUI.theProject.spellCheck # Check the files - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "0_nwProject.nwx") refFile = os.path.join(refDir, "gui", "0_nwProject.nwx") copyfile(projFile, testFile) @@ -140,7 +140,7 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, refDir, tmpDir): # qtbot.stopForInteraction() # Re-open project - assert nwGUI.openProject(nwFuncTemp) + assert nwGUI.openProject(fncDir) qtbot.wait(stepDelay) # Check that we loaded the data @@ -148,8 +148,8 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, refDir, tmpDir): assert len(nwGUI.theProject.projTree._treeOrder) == 8 assert len(nwGUI.theProject.projTree._treeRoots) == 4 assert nwGUI.theProject.projTree.trashRoot() is None - assert nwGUI.theProject.projPath == nwFuncTemp - assert nwGUI.theProject.projMeta == os.path.join(nwFuncTemp, "meta") + assert nwGUI.theProject.projPath == fncDir + assert nwGUI.theProject.projMeta == os.path.join(fncDir, "meta") assert nwGUI.theProject.projFile == "nwProject.nwx" assert nwGUI.theProject.projName == "New Project" assert nwGUI.theProject.bookTitle == "" @@ -379,31 +379,31 @@ def testDocEditor(qtbot, yesToAll, nwFuncTemp, nwTempGUI, refDir, tmpDir): assert nwGUI.saveProject() # Check the files - projFile = os.path.join(nwFuncTemp, "nwProject.nwx") + projFile = os.path.join(fncDir, "nwProject.nwx") testFile = os.path.join(nwTempGUI, "1_nwProject.nwx") refFile = os.path.join(refDir, "gui", "1_nwProject.nwx") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile, [2, 6, 7, 8]) - projFile = os.path.join(nwFuncTemp, "content", "031b4af5197ec.nwd") + projFile = os.path.join(fncDir, "content", "031b4af5197ec.nwd") testFile = os.path.join(nwTempGUI, "1_031b4af5197ec.nwd") refFile = os.path.join(refDir, "gui", "1_031b4af5197ec.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) - projFile = os.path.join(nwFuncTemp, "content", "1a6562590ef19.nwd") + projFile = os.path.join(fncDir, "content", "1a6562590ef19.nwd") testFile = os.path.join(nwTempGUI, "1_1a6562590ef19.nwd") refFile = os.path.join(refDir, "gui", "1_1a6562590ef19.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) - projFile = os.path.join(nwFuncTemp, "content", "0e17daca5f3e1.nwd") + projFile = os.path.join(fncDir, "content", "0e17daca5f3e1.nwd") testFile = os.path.join(nwTempGUI, "1_0e17daca5f3e1.nwd") refFile = os.path.join(refDir, "gui", "1_0e17daca5f3e1.nwd") copyfile(projFile, testFile) assert cmpFiles(testFile, refFile) - projFile = os.path.join(nwFuncTemp, "content", "41cfc0d1f2d12.nwd") + projFile = os.path.join(fncDir, "content", "41cfc0d1f2d12.nwd") testFile = os.path.join(nwTempGUI, "1_41cfc0d1f2d12.nwd") refFile = os.path.join(refDir, "gui", "1_41cfc0d1f2d12.nwd") copyfile(projFile, testFile) @@ -1013,7 +1013,7 @@ def testContextMenu(qtbot, yesToAll, nwLipsum, tmpDir): nwGUI.close() @pytest.mark.gui -def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, tmpDir): +def testInsertMenu(qtbot, monkeypatch, fncDir, tmpDir): nwGUI = nw.main(["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir]) qtbot.addWidget(nwGUI) nwGUI.show() @@ -1021,7 +1021,7 @@ def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, tmpDir): qtbot.wait(stepDelay) nwGUI.theProject.projTree.setSeed(42) - assert nwGUI.newProject({"projPath": nwFuncTemp}) + assert nwGUI.newProject({"projPath": fncDir}) assert nwGUI.treeView._getTreeItem("0e17daca5f3e1") is not None @@ -1205,7 +1205,7 @@ def testInsertMenu(qtbot, monkeypatch, nwFuncTemp, tmpDir): assert len(theBits) == 3 assert theBits[0] == "File details for the currently open file" assert theBits[1] == "Handle: 0e17daca5f3e1" - assert theBits[2] == "Location: %s" % os.path.join(nwFuncTemp, "content", "0e17daca5f3e1.nwd") + assert theBits[2] == "Location: %s" % os.path.join(fncDir, "content", "0e17daca5f3e1.nwd") # qtbot.stopForInteraction() nwGUI.closeMain() From 51cb9814d2f035622a983c22a57f03dd7874d53d Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 5 Dec 2020 21:10:34 +0100 Subject: [PATCH 32/52] Rename GUI test files --- tests/{test_dialogs.py => test_gui_dialogs.py} | 0 tests/{test_gui.py => test_gui_main.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/{test_dialogs.py => test_gui_dialogs.py} (100%) rename tests/{test_gui.py => test_gui_main.py} (100%) diff --git a/tests/test_dialogs.py b/tests/test_gui_dialogs.py similarity index 100% rename from tests/test_dialogs.py rename to tests/test_gui_dialogs.py diff --git a/tests/test_gui.py b/tests/test_gui_main.py similarity index 100% rename from tests/test_gui.py rename to tests/test_gui_main.py From bd9ed0d5d3c003b13692d87871fa513eff0cd6e6 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 5 Dec 2020 21:24:19 +0100 Subject: [PATCH 33/52] Fix testCoreProject_Methods test on Windows --- tests/conftest.py | 3 --- tests/test_core_project.py | 5 +++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5195b841..0d66d0ea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,15 +6,12 @@ import sys import pytest import shutil import os -import time from dummy import DummyMain from PyQt5.QtWidgets import QMessageBox sys.path.insert(1, os.path.abspath(os.path.join(os.path.dirname(__file__), os.path.pardir))) -os.environ["TZ"] = "UTC" -time.tzset() from nw.config import Config # noqa: E402 diff --git a/tests/test_core_project.py b/tests/test_core_project.py index 869603fe..fe847983 100644 --- a/tests/test_core_project.py +++ b/tests/test_core_project.py @@ -14,6 +14,7 @@ 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): @@ -842,8 +843,8 @@ def testCoreProject_Methods(monkeypatch, nwMinimal, dummyGUI, tmpDir): assert readFile(statsFile) == ( "# Offset 100\n" "# Start Time End Time Novel Notes\n" - "2020-09-13 13:00:00 2020-09-13 14:00:00 200 100\n" - ) + "%s %s 200 100\n" + ) % (formatTimeStamp(1600002000), formatTimeStamp(1600005600)) # Pack XML Value xElem = etree.Element("element") From d70ee34429cc3304c5e9c00788a9f50acc45a35b Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Dec 2020 00:29:38 +0100 Subject: [PATCH 34/52] Rename project test reference files --- ...x => coreProject_NewCustomA_nwProject.nwx} | 0 ...x => coreProject_NewCustomB_nwProject.nwx} | 0 ....nwx => coreProject_NewFile_nwProject.nwx} | 0 ...x => coreProject_NewMinimal_nwProject.nwx} | 0 ....nwx => coreProject_NewRoot_nwProject.nwx} | 0 tests/test_core_project.py | 20 +++++++++---------- 6 files changed, 10 insertions(+), 10 deletions(-) rename tests/reference/{coreProject_2_nwProject.nwx => coreProject_NewCustomA_nwProject.nwx} (100%) rename tests/reference/{coreProject_3_nwProject.nwx => coreProject_NewCustomB_nwProject.nwx} (100%) rename tests/reference/{coreProject_5_nwProject.nwx => coreProject_NewFile_nwProject.nwx} (100%) rename tests/reference/{coreProject_1_nwProject.nwx => coreProject_NewMinimal_nwProject.nwx} (100%) rename tests/reference/{coreProject_4_nwProject.nwx => coreProject_NewRoot_nwProject.nwx} (100%) diff --git a/tests/reference/coreProject_2_nwProject.nwx b/tests/reference/coreProject_NewCustomA_nwProject.nwx similarity index 100% rename from tests/reference/coreProject_2_nwProject.nwx rename to tests/reference/coreProject_NewCustomA_nwProject.nwx diff --git a/tests/reference/coreProject_3_nwProject.nwx b/tests/reference/coreProject_NewCustomB_nwProject.nwx similarity index 100% rename from tests/reference/coreProject_3_nwProject.nwx rename to tests/reference/coreProject_NewCustomB_nwProject.nwx diff --git a/tests/reference/coreProject_5_nwProject.nwx b/tests/reference/coreProject_NewFile_nwProject.nwx similarity index 100% rename from tests/reference/coreProject_5_nwProject.nwx rename to tests/reference/coreProject_NewFile_nwProject.nwx diff --git a/tests/reference/coreProject_1_nwProject.nwx b/tests/reference/coreProject_NewMinimal_nwProject.nwx similarity index 100% rename from tests/reference/coreProject_1_nwProject.nwx rename to tests/reference/coreProject_NewMinimal_nwProject.nwx diff --git a/tests/reference/coreProject_4_nwProject.nwx b/tests/reference/coreProject_NewRoot_nwProject.nwx similarity index 100% rename from tests/reference/coreProject_4_nwProject.nwx rename to tests/reference/coreProject_NewRoot_nwProject.nwx diff --git a/tests/test_core_project.py b/tests/test_core_project.py index fe847983..3abefbbc 100644 --- a/tests/test_core_project.py +++ b/tests/test_core_project.py @@ -22,8 +22,8 @@ def testCoreProject_NewMinimal(fncDir, outDir, refDir, tmpDir, dummyGUI): default setting, creating a Minimal project. """ projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_1_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_1_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) @@ -70,8 +70,8 @@ def testCoreProject_NewCustomA(fncDir, outDir, refDir, dummyGUI): Custom type with chapters and scenes. """ projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_2_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_2_nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_NewCustomA_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_NewCustomA_nwProject.nwx") projData = { "projName": "Test Custom", @@ -111,8 +111,8 @@ def testCoreProject_NewCustomB(fncDir, outDir, refDir, dummyGUI): Custom type without chapters, but with scenes. """ projFile = os.path.join(fncDir, "nwProject.nwx") - testFile = os.path.join(outDir, "coreProject_3_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_3_nwProject.nwx") + testFile = os.path.join(outDir, "coreProject_NewCustomB_nwProject.nwx") + compFile = os.path.join(refDir, "coreProject_NewCustomB_nwProject.nwx") projData = { "projName": "Test Custom", @@ -238,8 +238,8 @@ 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_4_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_4_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) @@ -274,8 +274,8 @@ 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_5_nwProject.nwx") - compFile = os.path.join(refDir, "coreProject_5_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) From 7c95e6dffacc99ba52c9cbdcb5e637df4749611d Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Dec 2020 01:06:37 +0100 Subject: [PATCH 35/52] Updated existing Error test --- pytest.ini | 2 +- tests/dummy.py | 3 ++ tests/test_base_error.py | 87 ++++++++++++++++++++++++++++++++++++++++ tests/test_error.py | 44 -------------------- 4 files changed, 91 insertions(+), 45 deletions(-) create mode 100644 tests/test_base_error.py delete mode 100644 tests/test_error.py diff --git a/pytest.ini b/pytest.ini index b61d59ec..c2278317 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,6 +1,6 @@ [pytest] markers = - error: Test various error handling scenarios + base: Base functionality tests core: Core functionality tests gui: Qt5 GUI tests serial diff --git a/tests/dummy.py b/tests/dummy.py index 0f26e0ff..2807578a 100644 --- a/tests/dummy.py +++ b/tests/dummy.py @@ -71,3 +71,6 @@ class StatusBar(): def causeOSError(*args, **kwargs): raise OSError + +def causeException(*args, **kwargs): + raise Exception diff --git a/tests/test_base_error.py b/tests/test_base_error.py new file mode 100644 index 00000000..e3bd689d --- /dev/null +++ b/tests/test_base_error.py @@ -0,0 +1,87 @@ +# -*- 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): + 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 + nwErr.setMessage(Exception, "First Error", None) + theMessage = nwErr.msgBody.toPlainText() + assert theMessage + assert "First Error" in theMessage + assert "Exception" in theMessage + nwErr._doClose() + nwErr.close() + + # Valid Error + monkeypatch.setattr("PyQt5.QtCore.QSysInfo.kernelVersion", lambda: "1.2.3") + theMessage = exceptionHandler(Exception, "Second Error", None, testMode=True) + assert theMessage + assert "Second 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) + theMessage = exceptionHandler(Exception, "Third Error", None, testMode=True) + assert theMessage + assert "Third Error" in theMessage + assert "Exception" in theMessage + assert "(Unknown)" in theMessage + monkeypatch.undo() + + # Normal shutdown, but not testmode + monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) + nwGUI.mainConf.showGUI = True + exceptionHandler(Exception, "Third Error", None, testMode=False) + nwGUI.mainConf.showGUI = False + monkeypatch.undo() + + # Disable blocking of GUI + monkeypatch.setattr("PyQt5.QtWidgets.QDialog.exec_", lambda: None) + exceptionHandler(Exception, "Third Error", None, testMode=True) + monkeypatch.undo() + + # Should handle qApp failing + monkeypatch.setattr("PyQt5.QtWidgets.qApp.topLevelWidgets", causeException) + exceptionHandler(Exception, "Third Error", None, testMode=True) + monkeypatch.undo() + + # Should handle failing to close main GUI + monkeypatch.setattr(nwGUI, "closeMain", causeException) + exceptionHandler(Exception, "Third Error", None, testMode=True) + monkeypatch.undo() + + # Should not crash when no GUI is found + nwGUI.setObjectName("Stuff") + assert exceptionHandler(Exception, "Third Error", None, testMode=True) is None + + nwGUI.closeMain() + + # qtbot.stopForInteraction() + +# END Test testBaseError_Dialog diff --git a/tests/test_error.py b/tests/test_error.py deleted file mode 100644 index 192fb6cb..00000000 --- a/tests/test_error.py +++ /dev/null @@ -1,44 +0,0 @@ -# -*- coding: utf-8 -*- -"""novelWriter Error Tester -""" - -import nw -import pytest - -from PyQt5.QtWidgets import qApp - -from nw.error import NWErrorMessage, exceptionHandler - -@pytest.mark.error -def testErrorDialog(qtbot, fncDir, tmpDir): - 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 - nwErr.setMessage(Exception, "Faulty Error", 123) - assert nwErr.msgBody.toPlainText() == "Failed to generate error report ..." - - # Valid Error - nwErr.setMessage(Exception, "First Error", None) - theMessage = nwErr.msgBody.toPlainText() - assert theMessage - assert "First Error" in theMessage - assert "Exception" in theMessage - nwErr._doClose() - nwErr.close() - - theMessage = exceptionHandler(Exception, "Second Error", None, testMode=True) - assert theMessage - assert "Second Error" in theMessage - assert "Exception" in theMessage - - nwGUI.closeMain() - - # qtbot.stopForInteraction() From 730b572d4fe66240c70fe49e57efc60044fd9492 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Dec 2020 12:46:10 +0100 Subject: [PATCH 36/52] Updated the Common tests to new format --- tests/README.md | 1 + tests/{test_common.py => test_base_common.py} | 82 ++++++++++++++----- 2 files changed, 62 insertions(+), 21 deletions(-) rename tests/{test_common.py => test_base_common.py} (78%) diff --git a/tests/README.md b/tests/README.md index cd1be55b..8be3b5b5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -61,6 +61,7 @@ The commands for the respective test categories are listed below. | Type | Test Target | Source File(s) | Marker | Filter | | :--- | :----------------- | :-------------------- | :-------- | :-------------------- | +| Unit | Common functions | nw/common.py | `-m base` | `-k testBaseCommon` | | 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` | 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 135627fc..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 @@ -11,8 +11,10 @@ from nw.common import ( ) 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 From 33c2dda471798d0d03781da32d21816e267151b5 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Dec 2020 13:01:49 +0100 Subject: [PATCH 37/52] Cleaned up error tests and removed testmode from error handler --- nw/error.py | 7 ++--- tests/test_base_error.py | 62 ++++++++++++++++++++++++---------------- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/nw/error.py b/nw/error.py index 988b49e6..6db88647 100644 --- a/nw/error.py +++ b/nw/error.py @@ -135,7 +135,7 @@ 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 @@ -173,10 +173,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/tests/test_base_error.py b/tests/test_base_error.py index e3bd689d..540b51f0 100644 --- a/tests/test_base_error.py +++ b/tests/test_base_error.py @@ -13,6 +13,8 @@ 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) @@ -28,60 +30,72 @@ def testBaseError_Dialog(qtbot, monkeypatch, fncDir, tmpDir): assert nwErr.msgBody.toPlainText() == "Failed to generate error report ..." # Valid Error Message - nwErr.setMessage(Exception, "First Error", None) + monkeypatch.setattr("PyQt5.QtCore.QSysInfo.kernelVersion", lambda: "1.2.3") + nwErr.setMessage(Exception, "Fine Error", None) theMessage = nwErr.msgBody.toPlainText() assert theMessage - assert "First Error" in theMessage - assert "Exception" in theMessage - nwErr._doClose() - nwErr.close() - - # Valid Error - monkeypatch.setattr("PyQt5.QtCore.QSysInfo.kernelVersion", lambda: "1.2.3") - theMessage = exceptionHandler(Exception, "Second Error", None, testMode=True) - assert theMessage - assert "Second Error" in 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) - theMessage = exceptionHandler(Exception, "Third Error", None, testMode=True) + nwErr.setMessage(Exception, "Almost Fine Error", None) + theMessage = nwErr.msgBody.toPlainText() assert theMessage - assert "Third Error" in theMessage - assert "Exception" in theMessage assert "(Unknown)" in theMessage monkeypatch.undo() - # Normal shutdown, but not testmode + 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("PyQt5.QtWidgets.qApp.exit", lambda x: None) nwGUI.mainConf.showGUI = True - exceptionHandler(Exception, "Third Error", None, testMode=False) + exceptionHandler(Exception, "Error Message", None) nwGUI.mainConf.showGUI = False monkeypatch.undo() # Disable blocking of GUI monkeypatch.setattr("PyQt5.QtWidgets.QDialog.exec_", lambda: None) - exceptionHandler(Exception, "Third Error", None, testMode=True) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) + exceptionHandler(Exception, "Error Message", None) monkeypatch.undo() # Should handle qApp failing monkeypatch.setattr("PyQt5.QtWidgets.qApp.topLevelWidgets", causeException) - exceptionHandler(Exception, "Third Error", None, testMode=True) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) + exceptionHandler(Exception, "Error Message", None) monkeypatch.undo() # Should handle failing to close main GUI monkeypatch.setattr(nwGUI, "closeMain", causeException) - exceptionHandler(Exception, "Third Error", None, testMode=True) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) + exceptionHandler(Exception, "Error Message", None) monkeypatch.undo() # Should not crash when no GUI is found - nwGUI.setObjectName("Stuff") - assert exceptionHandler(Exception, "Third Error", None, testMode=True) is None + monkeypatch.setattr("PyQt5.QtWidgets.qApp.topLevelWidgets", lambda: []) + monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) + exceptionHandler(Exception, "Error Message", None) + monkeypatch.undo() nwGUI.closeMain() - # qtbot.stopForInteraction() - -# END Test testBaseError_Dialog +# END Test testBaseError_Handler From cdeee65505f89b3d3768119e2153dd1b344c4f0d Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Dec 2020 13:21:47 +0100 Subject: [PATCH 38/52] Also remove the gui blocking from the error handler --- nw/error.py | 4 +--- tests/test_base_error.py | 40 ++++++++++++++++++---------------------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/nw/error.py b/nw/error.py index 6db88647..24a802aa 100644 --- a/nw/error.py +++ b/nw/error.py @@ -138,7 +138,6 @@ class NWErrorMessage(QDialog): 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): errMsg = NWErrorMessage(nwGUI) errMsg.setMessage(exType, exValue, exTrace) - if nw.CONFIG.showGUI: - errMsg.exec_() + errMsg.exec_() try: # Try a controlled shudown diff --git a/tests/test_base_error.py b/tests/test_base_error.py index 540b51f0..472faaa2 100644 --- a/tests/test_base_error.py +++ b/tests/test_base_error.py @@ -66,33 +66,29 @@ def testBaseError_Handler(qtbot, monkeypatch, fncDir, tmpDir): qtbot.waitForWindowShown(nwGUI) # Normal shutdown - monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) - nwGUI.mainConf.showGUI = True - exceptionHandler(Exception, "Error Message", None) - nwGUI.mainConf.showGUI = False - monkeypatch.undo() - - # Disable blocking of GUI - monkeypatch.setattr("PyQt5.QtWidgets.QDialog.exec_", lambda: None) - monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) - exceptionHandler(Exception, "Error Message", None) - monkeypatch.undo() - - # Should handle qApp failing - monkeypatch.setattr("PyQt5.QtWidgets.qApp.topLevelWidgets", causeException) - monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) - exceptionHandler(Exception, "Error Message", None) - monkeypatch.undo() - - # Should handle failing to close main GUI - monkeypatch.setattr(nwGUI, "closeMain", causeException) - monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) + 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: []) - monkeypatch.setattr("PyQt5.QtWidgets.qApp.exit", lambda x: None) + 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() From ee7a5e1681d3f1459911640a49a1c26345820aeb Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sun, 6 Dec 2020 13:27:31 +0100 Subject: [PATCH 39/52] Moved the launch test from main to init --- tests/test_base_init.py | 87 +++++++++++++++++++++++++++++++++++++++++ tests/test_gui_main.py | 78 ------------------------------------ 2 files changed, 87 insertions(+), 78 deletions(-) create mode 100644 tests/test_base_init.py diff --git a/tests/test_base_init.py b/tests/test_base_init.py new file mode 100644 index 00000000..c45dc65f --- /dev/null +++ b/tests/test_base_init.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""novelWriter Main Init Tester +""" + +import nw +import pytest +import logging +import sys + +@pytest.mark.base +def testBaseInit_Launch(qtbot, monkeypatch, fncDir, tmpDir): + """Test the main __init__.py file. + """ + # Defaults + nwGUI = nw.main( + ["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir, "--style=Fusion"] + ) + assert nw.logger.getEffectiveLevel() == logging.WARNING + nwGUI.closeMain() + nwGUI.close() + + # Log Levels + nwGUI = nw.main( + ["--testmode", "--info", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ) + assert nw.logger.getEffectiveLevel() == logging.INFO + nwGUI.closeMain() + nwGUI.close() + + nwGUI = nw.main( + ["--testmode", "--debug", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ) + assert nw.logger.getEffectiveLevel() == logging.DEBUG + nwGUI.closeMain() + nwGUI.close() + + nwGUI = nw.main( + ["--testmode", "--verbose", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ) + assert nw.logger.getEffectiveLevel() == 5 + nwGUI.closeMain() + nwGUI.close() + + # Help and Version + with pytest.raises(SystemExit) as ex: + nwGUI = nw.main( + ["--testmode", "--help", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ) + nwGUI.closeMain() + nwGUI.close() + assert ex.value.code == 0 + + with pytest.raises(SystemExit) as ex: + nwGUI = nw.main( + ["--testmode", "--version", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ) + nwGUI.closeMain() + nwGUI.close() + assert ex.value.code == 0 + + # Invalid options + with pytest.raises(SystemExit) as ex: + nwGUI = nw.main( + ["--testmode", "--invalid", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ) + nwGUI.closeMain() + nwGUI.close() + assert ex.value.code == 2 + + # Simulate import error + monkeypatch.setitem(sys.modules, "lxml", None) + monkeypatch.setattr("sys.hexversion", 0x0) + monkeypatch.setattr("nw.CONFIG.verQtValue", 50000) + monkeypatch.setattr("nw.CONFIG.verPyQtValue", 50000) + with pytest.raises(SystemExit) as ex: + nwGUI = nw.main( + ["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ) + nwGUI.closeMain() + nwGUI.close() + assert ex.value.code & 4 == 4 # Python version not satisfied + assert ex.value.code & 8 == 8 # Qt version not satisfied + assert ex.value.code & 16 == 16 # PyQt version not satisfied + assert ex.value.code & 32 == 32 # lxml package missing + monkeypatch.undo() + +# END Test testBaseInit_Launch diff --git a/tests/test_gui_main.py b/tests/test_gui_main.py index a10c0582..b56c525b 100644 --- a/tests/test_gui_main.py +++ b/tests/test_gui_main.py @@ -4,9 +4,7 @@ import nw import pytest -import logging import os -import sys from shutil import copyfile from tools import cmpFiles @@ -26,82 +24,6 @@ keyDelay = 2 typeDelay = 1 stepDelay = 20 -@pytest.mark.gui -def testLaunch(qtbot, monkeypatch, fncDir, tmpDir): - - # Defaults - nwGUI = nw.main( - ["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir, "--style=Fusion"] - ) - assert nw.logger.getEffectiveLevel() == logging.WARNING - nwGUI.closeMain() - nwGUI.close() - - # Log Levels - nwGUI = nw.main( - ["--testmode", "--info", "--config=%s" % fncDir, "--data=%s" % tmpDir] - ) - assert nw.logger.getEffectiveLevel() == logging.INFO - nwGUI.closeMain() - nwGUI.close() - - nwGUI = nw.main( - ["--testmode", "--debug", "--config=%s" % fncDir, "--data=%s" % tmpDir] - ) - assert nw.logger.getEffectiveLevel() == logging.DEBUG - nwGUI.closeMain() - nwGUI.close() - - nwGUI = nw.main( - ["--testmode", "--verbose", "--config=%s" % fncDir, "--data=%s" % tmpDir] - ) - assert nw.logger.getEffectiveLevel() == 5 - nwGUI.closeMain() - nwGUI.close() - - # Help and Version - with pytest.raises(SystemExit) as ex: - nwGUI = nw.main( - ["--testmode", "--help", "--config=%s" % fncDir, "--data=%s" % tmpDir] - ) - nwGUI.closeMain() - nwGUI.close() - assert ex.value.code == 0 - - with pytest.raises(SystemExit) as ex: - nwGUI = nw.main( - ["--testmode", "--version", "--config=%s" % fncDir, "--data=%s" % tmpDir] - ) - nwGUI.closeMain() - nwGUI.close() - assert ex.value.code == 0 - - # Invalid options - with pytest.raises(SystemExit) as ex: - nwGUI = nw.main( - ["--testmode", "--invalid", "--config=%s" % fncDir, "--data=%s" % tmpDir] - ) - nwGUI.closeMain() - nwGUI.close() - assert ex.value.code == 2 - - # Simulate import error - monkeypatch.setitem(sys.modules, "lxml", None) - monkeypatch.setattr("sys.hexversion", 0x0) - monkeypatch.setattr("nw.CONFIG.verQtValue", 50000) - monkeypatch.setattr("nw.CONFIG.verPyQtValue", 50000) - with pytest.raises(SystemExit) as ex: - nwGUI = nw.main( - ["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir] - ) - nwGUI.closeMain() - nwGUI.close() - assert ex.value.code & 4 == 4 # Python version not satisfied - assert ex.value.code & 8 == 8 # Qt version not satisfied - assert ex.value.code & 16 == 16 # PyQt version not satisfied - assert ex.value.code & 32 == 32 # lxml package missing - monkeypatch.undo() - @pytest.mark.gui def testDocEditor(qtbot, yesToAll, fncDir, nwTempGUI, refDir, tmpDir): From a4ee1e112c2b62f3c49cb0e48098752379344a79 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Mon, 7 Dec 2020 20:00:58 +0100 Subject: [PATCH 40/52] Updated and extended the testing of the main function --- nw/__init__.py | 29 ++++----- tests/README.md | 2 + tests/dummy.py | 14 +++-- tests/test_base_init.py | 134 +++++++++++++++++++++++++++++++--------- 4 files changed, 132 insertions(+), 47 deletions(-) diff --git a/nw/__init__.py b/nw/__init__.py index a5960f30..65ab7f59 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -246,19 +246,20 @@ def main(sysArgs=None): errorCode |= 32 if errorData: - if not testMode: - errApp = QApplication([]) - errMsg = QErrorMessage() - errMsg.resize(500, 300) - errMsg.showMessage(( - "

A critical error has been encountered

" - "

novelWriter cannot start due to the following issues:

" - "

 - %s

" - "

Shutting down ...

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

A critical error has been encountered

" + "

novelWriter cannot start due to the following issues:

" + "

 - %s

" + "

Shutting down ...

" + ) % ( + "
 - ".join(errorData) + )) + for errMsg in errorData: + logger.critical(errMsg) + errApp.exec_() sys.exit(errorCode) # Finish initialising config @@ -293,4 +294,4 @@ def main(sysArgs=None): nwGUI = GuiMain() sys.exit(nwApp.exec_()) - return +# END Function main diff --git a/tests/README.md b/tests/README.md index 8be3b5b5..09dcd6ce 100644 --- a/tests/README.md +++ b/tests/README.md @@ -61,7 +61,9 @@ 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 | 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` | diff --git a/tests/dummy.py b/tests/dummy.py index 2807578a..daf0e070 100644 --- a/tests/dummy.py +++ b/tests/dummy.py @@ -13,7 +13,7 @@ class DummyMain(): self.hasProject = True self.theIndex = None self.theProject = None - self.statusBar = StatusBar() + self.statusBar = DummyStatusBar() # Test Variables self.askResponse = True @@ -42,6 +42,12 @@ class DummyMain(): def rebuildIndex(self): return + def closeMain(self): + return "closeMain" + + def close(self): + return "close" + # Test Functions def undo(self): @@ -52,9 +58,9 @@ class DummyMain(): self.lastAlert = "" return -# END Class GuiMain +# END Class DummyMain -class StatusBar(): +class DummyStatusBar(): def __init__(self): return @@ -62,7 +68,7 @@ class StatusBar(): def setStatus(self, theText): return -# END Class StatusBar +# END Class DummyStatusBar # =========================================================================== # # Error Functions diff --git a/tests/test_base_init.py b/tests/test_base_init.py index c45dc65f..b691bfa1 100644 --- a/tests/test_base_init.py +++ b/tests/test_base_init.py @@ -7,81 +7,157 @@ import pytest import logging import sys +from dummy import DummyMain + @pytest.mark.base -def testBaseInit_Launch(qtbot, monkeypatch, fncDir, tmpDir): - """Test the main __init__.py file. +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" % fncDir, "--data=%s" % tmpDir, "--style=Fusion"] + ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir, "--style=Fusion"] ) assert nw.logger.getEffectiveLevel() == logging.WARNING - nwGUI.closeMain() - nwGUI.close() + assert nw.CONFIG.debugInfo is False + assert nwGUI.closeMain() == "closeMain" # Log Levels nwGUI = nw.main( - ["--testmode", "--info", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ["--testmode", "--info", "--config=%s" % tmpDir, "--data=%s" % tmpDir] ) assert nw.logger.getEffectiveLevel() == logging.INFO - nwGUI.closeMain() - nwGUI.close() + assert nw.CONFIG.debugInfo is False + assert nwGUI.closeMain() == "closeMain" nwGUI = nw.main( - ["--testmode", "--debug", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ["--testmode", "--debug", "--config=%s" % tmpDir, "--data=%s" % tmpDir] ) assert nw.logger.getEffectiveLevel() == logging.DEBUG - nwGUI.closeMain() - nwGUI.close() + assert nw.CONFIG.debugInfo is True + assert nwGUI.closeMain() == "closeMain" nwGUI = nw.main( - ["--testmode", "--verbose", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ["--testmode", "--verbose", "--config=%s" % tmpDir, "--data=%s" % tmpDir] ) assert nw.logger.getEffectiveLevel() == 5 - nwGUI.closeMain() - nwGUI.close() + 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" % fncDir, "--data=%s" % tmpDir] + ["--testmode", "--help", "--config=%s" % tmpDir, "--data=%s" % tmpDir] ) - nwGUI.closeMain() - nwGUI.close() + assert nwGUI.closeMain() == "closeMain" assert ex.value.code == 0 with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--version", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ["--testmode", "--version", "--config=%s" % tmpDir, "--data=%s" % tmpDir] ) - nwGUI.closeMain() - nwGUI.close() + assert nwGUI.closeMain() == "closeMain" assert ex.value.code == 0 # Invalid options with pytest.raises(SystemExit) as ex: nwGUI = nw.main( - ["--testmode", "--invalid", "--config=%s" % fncDir, "--data=%s" % tmpDir] + ["--testmode", "--invalid", "--config=%s" % tmpDir, "--data=%s" % tmpDir] ) - nwGUI.closeMain() - nwGUI.close() + assert nwGUI.closeMain() == "closeMain" assert ex.value.code == 2 - # Simulate import error + # 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: - nwGUI = nw.main( - ["--testmode", "--config=%s" % fncDir, "--data=%s" % tmpDir] + _ = nw.main( + ["--testmode", "--config=%s" % tmpDir, "--data=%s" % tmpDir] ) - nwGUI.closeMain() - nwGUI.close() + assert ex.value.code & 4 == 4 # Python version not satisfied assert ex.value.code & 8 == 8 # Qt version not satisfied assert ex.value.code & 16 == 16 # PyQt version not satisfied assert ex.value.code & 32 == 32 # lxml package missing + + 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_Launch +# END Test testBaseInit_Imports From 9772c85ece343c58579da6bf2c511e6c6aa1bea0 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Mon, 7 Dec 2020 23:34:12 +0100 Subject: [PATCH 41/52] Rewritten Config class test --- nw/config.py | 91 ++-- tests/conftest.py | 3 + ...riter.conf => baseConfig_novelwriter.conf} | 0 tests/test_base_config.py | 505 ++++++++++++++++++ tests/test_config.py | 165 ------ 5 files changed, 559 insertions(+), 205 deletions(-) rename tests/reference/{novelwriter.conf => baseConfig_novelwriter.conf} (100%) create mode 100644 tests/test_base_config.py delete mode 100644 tests/test_config.py diff --git a/nw/config.py b/nw/config.py index 04c1a4db..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): @@ -211,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 @@ -321,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() @@ -388,25 +390,25 @@ class Config: ## 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 @@ -484,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 @@ -901,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. """ @@ -930,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 @@ -961,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/tests/conftest.py b/tests/conftest.py index 0d66d0ea..ea9a92b3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,6 +73,9 @@ def fncDir(tmpDir): 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("") diff --git a/tests/reference/novelwriter.conf b/tests/reference/baseConfig_novelwriter.conf similarity index 100% rename from tests/reference/novelwriter.conf rename to tests/reference/baseConfig_novelwriter.conf diff --git a/tests/test_base_config.py b/tests/test_base_config.py new file mode 100644 index 00000000..f7aacc50 --- /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, readFile + +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_config.py b/tests/test_config.py deleted file mode 100644 index 94ff5d1c..00000000 --- a/tests/test_config.py +++ /dev/null @@ -1,165 +0,0 @@ -# -*- coding: utf-8 -*- -"""novelWriter Config Class Tester -""" - -import pytest -import os - -from tools import cmpFiles - -@pytest.mark.core -def testConfigCore(tmpConf, tmpDir, refDir): - refConf = os.path.join(refDir, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - - assert tmpConf.confPath == tmpDir - assert tmpConf.saveConfig() - assert cmpFiles(testConf, refConf, [2, 9]) - assert not tmpConf.confChanged - - assert tmpConf.loadConfig() - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigSetConfPath(tmpConf, tmpDir): - 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 - -@pytest.mark.core -def testConfigSetDataPath(tmpConf, tmpDir): - 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 - -@pytest.mark.core -def testConfigSetWinSize(tmpConf, tmpDir, refDir): - refConf = os.path.join(refDir, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - tmpConf.guiScale = 1.0 - - assert tmpConf.confPath == tmpDir - 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, 9]) - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigSetTreeColWidths(tmpConf, tmpDir, refDir): - refConf = os.path.join(refDir, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - - assert tmpConf.confPath == tmpDir - 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, 9]) - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigSetPanePos(tmpConf, tmpDir, refDir): - refConf = os.path.join(refDir, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - - assert tmpConf.confPath == tmpDir - - 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, 9]) - assert not tmpConf.confChanged - -@pytest.mark.core -def testConfigFlags(tmpConf, tmpDir, refDir): - refConf = os.path.join(refDir, "novelwriter.conf") - testConf = os.path.join(tmpConf.confPath, "novelwriter.conf") - - assert tmpConf.confPath == tmpDir - - 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, 9]) - assert not tmpConf.confChanged - -@pytest.mark.core -def testTextSizes(tmpConf, tmpDir, refDir): - assert tmpConf.confPath == tmpDir - - 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 From 330ee77e58fc7fa51b527258edd91a3e2a2ad608 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Mon, 7 Dec 2020 23:39:54 +0100 Subject: [PATCH 42/52] Fix flake8 fail --- tests/test_base_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_base_config.py b/tests/test_base_config.py index f7aacc50..fe755646 100644 --- a/tests/test_base_config.py +++ b/tests/test_base_config.py @@ -10,7 +10,7 @@ import configparser from shutil import copyfile from dummy import causeOSError -from tools import cmpFiles, readFile +from tools import cmpFiles from nw.config import Config from nw.constants import nwConst, nwFiles From 1469953b5e452effc75dd0726ca733383c6a6f3d Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Mon, 7 Dec 2020 23:45:14 +0100 Subject: [PATCH 43/52] Updated test README --- tests/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/README.md b/tests/README.md index 09dcd6ce..7d8e378d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -63,6 +63,7 @@ The commands for the respective test categories are listed below. | :--- | :----------------- | :-------------------- | :-------- | :-------------------- | | 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` | From 5cc6e4c5436dac765c3c92a2ff7bde9f72102c98 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 8 Dec 2020 17:32:42 +0100 Subject: [PATCH 44/52] Update text on About and Preferences dialogs --- nw/gui/about.py | 4 +-- nw/gui/preferences.py | 82 +++++++++++++++++++++++++------------------ nw/gui/theme.py | 14 ++++---- 3 files changed, 56 insertions(+), 44 deletions(-) diff --git a/nw/gui/about.py b/nw/gui/about.py index 99a4d296..c0c7885e 100644 --- a/nw/gui/about.py +++ b/nw/gui/about.py @@ -140,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

" diff --git a/nw/gui/preferences.py b/nw/gui/preferences.py index 2bb22bde..8cf47f04 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 used 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 used 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 used 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 used for a trailing double quote.", theButton=self.btnDoubleStyleC ) 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 From 9ffb5e6a16f4f88547d716520ea1ad77dd775e19 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 8 Dec 2020 17:33:34 +0100 Subject: [PATCH 45/52] Add grey syntax themes and fix automatic update when changing them --- nw/assets/themes/syntax/default_light.conf | 2 +- nw/assets/themes/syntax/grey_dark.conf | 25 ++++++++++++++++++++++ nw/assets/themes/syntax/grey_light.conf | 25 ++++++++++++++++++++++ nw/gui/doceditor.py | 7 +++--- nw/gui/docviewer.py | 3 ++- 5 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 nw/assets/themes/syntax/grey_dark.conf create mode 100644 nw/assets/themes/syntax/grey_light.conf 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/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 f3badad5..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 @@ -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): From cd675b23d155635d48ae776c40b3721dfd7ccc16 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Tue, 8 Dec 2020 17:41:09 +0100 Subject: [PATCH 46/52] Change some preferences help lines --- nw/gui/preferences.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nw/gui/preferences.py b/nw/gui/preferences.py index 8cf47f04..389c9f6f 100644 --- a/nw/gui/preferences.py +++ b/nw/gui/preferences.py @@ -946,7 +946,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Single quote open style", self.quoteSym["SO"], - "The symbol used for a leading single quote.", + "The symbol to use for a leading single quote.", theButton=self.btnSingleStyleO ) @@ -962,7 +962,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Single quote close style", self.quoteSym["SC"], - "The symbol used for a trailing single quote.", + "The symbol to use for a trailing single quote.", theButton=self.btnSingleStyleC ) @@ -979,7 +979,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Double quote open style", self.quoteSym["DO"], - "The symbol used for a leading double quote.", + "The symbol to use for a leading double quote.", theButton=self.btnDoubleStyleO ) @@ -995,7 +995,7 @@ class GuiConfigEditAutoReplaceTab(QWidget): self.mainForm.addRow( "Double quote close style", self.quoteSym["DC"], - "The symbol used for a trailing double quote.", + "The symbol to use for a trailing double quote.", theButton=self.btnDoubleStyleC ) From 8ea153da54b0f18fd9c39fb83dcf7f026cc71d6c Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Wed, 9 Dec 2020 09:21:37 +0100 Subject: [PATCH 47/52] Allow creating files in the 'Outtakes' root folder --- nw/gui/projtree.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 4116a863..60bcbf88 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -1027,9 +1027,8 @@ class GuiProjectTreeMenu(QMenu): """Update item settings from the nwItem. """ 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 +1037,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 From e12c41d7e70a4dd44949ebff2e6ba3a13af9b910 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Wed, 9 Dec 2020 09:26:21 +0100 Subject: [PATCH 48/52] Clarify docstring of project tree context menu filterAction --- nw/gui/projtree.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index 60bcbf88..dcd7e448 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -1024,7 +1024,8 @@ 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 From 5108c03fecaa6a83762273c3ae1bd3eb6b2b231e Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Wed, 9 Dec 2020 10:03:44 +0100 Subject: [PATCH 49/52] Add the GitHub Discussions link to the Help menu --- nw/__init__.py | 9 +++++---- nw/gui/mainmenu.py | 31 ++++++++++++++++++++----------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/nw/__init__.py b/nw/__init__.py index 65ab7f59..c9fae2d6 100644 --- a/nw/__init__.py +++ b/nw/__init__.py @@ -58,20 +58,21 @@ from nw.config import Config # __package__ = "nw" -__author__ = "Veronica Berglyd Olsen" __copyright__ = "Copyright 2018–2020, Veronica Berglyd Olsen" __license__ = "GPLv3" +__author__ = "Veronica Berglyd Olsen" +__maintainer__ = "Veronica Berglyd Olsen" +__email__ = "code@vkbo.net" __version__ = "1.0rc1" __hexversion__ = "0x010000c1" __date__ = "2020-11-16" -__maintainer__ = "Veronica Berglyd Olsen" -__email__ = "code@vkbo.net" __status__ = "Beta" +__domain__ = "novelwriter.io" __url__ = "https://novelwriter.io" __sourceurl__ = "https://github.com/vkbo/novelWriter" __issuesurl__ = "https://github.com/vkbo/novelWriter/issues" +__helpurl__ = "https://github.com/vkbo/novelWriter/discussions" __releaseurl__ = "https://github.com/vkbo/novelWriter/releases/latest" -__domain__ = "novelwriter.io" __docurl__ = "https://novelwriter.readthedocs.io" __credits__ = [ "Veronica Berglyd Olsen (developer)", diff --git a/nw/gui/mainmenu.py b/nw/gui/mainmenu.py index 309e99bb..861126c2 100644 --- a/nw/gui/mainmenu.py +++ b/nw/gui/mainmenu.py @@ -896,12 +896,6 @@ class GuiMainMenu(QMenuBar): self.aAboutQt.triggered.connect(lambda: self.theParent.showAboutQtDialog()) self.helpMenu.addAction(self.aAboutQt) - # Document > Main Website - self.aWebsite = QAction("Main Website", self) - self.aWebsite.setStatusTip("Open the main website at %s" % nw.__url__) - self.aWebsite.triggered.connect(lambda: self._openWebsite(nw.__url__)) - self.helpMenu.addAction(self.aWebsite) - # Help > Separator self.helpMenu.addSeparator() @@ -922,17 +916,32 @@ class GuiMainMenu(QMenuBar): self.aHelpWeb.setShortcuts(["F1", "Shift+F1"]) self.helpMenu.addAction(self.aHelpWeb) - # Document > Report Issue + # Help > Separator + self.helpMenu.addSeparator() + + # 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.aIssue = QAction("Latest Release (GitHub)", self) - self.aIssue.setStatusTip("Open the Releases page on GitHub at %s" % nw.__releaseurl__) - self.aIssue.triggered.connect(lambda: self._openWebsite(nw.__releaseurl__)) - self.helpMenu.addAction(self.aIssue) + 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 From c2321c502f2c7969a4761cab563c9cffcbe95e30 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Wed, 9 Dec 2020 23:19:02 +0100 Subject: [PATCH 50/52] Ensure that the moveTreeItem function exits properly if no item to be moved is selected --- nw/gui/projtree.py | 52 +++++++++++++++++++++++----------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index dcd7e448..a556b044 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 From 15c5ffcb7695eacade7122fdaccf4931e3b1c891 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Wed, 9 Dec 2020 23:22:15 +0100 Subject: [PATCH 51/52] Break up another nested if statement --- nw/gui/projtree.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/nw/gui/projtree.py b/nw/gui/projtree.py index a556b044..3e91cc2d 100644 --- a/nw/gui/projtree.py +++ b/nw/gui/projtree.py @@ -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 From 0fdee9922725c9738ee2662595e455051dc8c1d6 Mon Sep 17 00:00:00 2001 From: "Veronica K. B. Olsen" <1619840+vkbo@users.noreply.github.com> Date: Sat, 12 Dec 2020 10:19:05 +0100 Subject: [PATCH 52/52] Updated the github templates --- .github/ISSUE_TEMPLATE/bug-report.md | 24 ++++++++--------------- .github/ISSUE_TEMPLATE/feature-request.md | 17 +++++++--------- 2 files changed, 15 insertions(+), 26 deletions(-) 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.