diff --git a/novelwriter/tools/manuscript.py b/novelwriter/tools/manuscript.py index b2344aacd..762767cc4 100644 --- a/novelwriter/tools/manuscript.py +++ b/novelwriter/tools/manuscript.py @@ -32,11 +32,11 @@ from PyQt5.QtGui import QColor, QCursor, QFont, QPalette, QResizeEvent from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSlot -from PyQt5.QtPrintSupport import QPrintPreviewDialog, QPrinter from PyQt5.QtWidgets import ( QDialog, QGridLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSplitter, QTextBrowser, QToolButton, QVBoxLayout, QWidget, qApp ) +from PyQt5.QtPrintSupport import QPrintPreviewDialog, QPrinter from novelwriter import CONFIG from novelwriter.error import logException @@ -300,7 +300,6 @@ def _generatePreview(self): for step, _ in docBuild.iterBuildHTML(None): self.docPreview.buildStep(step + 1) qApp.processEvents() - qApp.thread().msleep(5) buildObj = docBuild.lastBuild assert isinstance(buildObj, ToHtml) @@ -528,10 +527,11 @@ def setJustify(self, state: bool): def setTextFont(self, family: str, size: int): """Set the text font properties.""" - font = QFont() - font.setFamily(family) - font.setPointSize(size) - self.setFont(font) + if family: + font = QFont() + font.setFamily(family) + font.setPointSize(size) + self.setFont(font) return ## @@ -617,6 +617,7 @@ def printPreview(self, printer: QPrinter): self.document().print(printer) qApp.restoreOverrideCursor() return + ## # Private Slots ## diff --git a/tests/test_tools/test_tools_manuscript.py b/tests/test_tools/test_tools_manuscript.py new file mode 100644 index 000000000..a57316576 --- /dev/null +++ b/tests/test_tools/test_tools_manuscript.py @@ -0,0 +1,242 @@ +""" +novelWriter – Manuscript Tool Tester +==================================== + +This file is a part of novelWriter +Copyright 2018–2023, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or 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. + +This program 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 GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" + +import pytest + +from pathlib import Path +from pytestqt.qtbot import QtBot + +from tools import C, buildTestProject, getGuiItem +from mocked import causeOSError + +from PyQt5.QtCore import Qt, pyqtSlot +from PyQt5.QtWidgets import QDialogButtonBox +from PyQt5.QtPrintSupport import QPrintPreviewDialog + +from novelwriter import CONFIG +from novelwriter.guimain import GuiMain +from novelwriter.core.buildsettings import BuildSettings +from novelwriter.tools.manuscript import GuiManuscript +from novelwriter.tools.manusbuild import GuiManuscriptBuild +from novelwriter.tools.manussettings import GuiBuildSettings + + +@pytest.mark.gui +def testManuscript_Init(monkeypatch, qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd): + """Test the init/main functionality of the GuiManuscript dialog.""" + buildTestProject(nwGUI, projPath) + nwGUI.openProject(projPath) + nwGUI.theProject.storage.getDocument(C.hChapterDoc).writeDocument("## A Chapter\n\n\t\tHi") + allText = "New Novel\nBy Jane Doe\nA Chapter\n\t\tHi\n* * *" + + manus = GuiManuscript(nwGUI) + manus.show() + manus.loadContent() + assert manus.docPreview.toPlainText().strip() == "" + + # Run the default build + manus.buildList.clearSelection() + manus.buildList.setCurrentRow(0) + with qtbot.waitSignal(manus.docPreview.document().contentsChanged): + manus.btnPreview.click() + assert manus.docPreview.toPlainText().strip() == allText + + manus.close() + + # A new dialog should load the old build + manus = GuiManuscript(nwGUI) + manus.show() + manus.loadContent() + assert manus.docPreview.toPlainText().strip() == allText + manus.close() + + # But blocking the reload should leave it empty + with monkeypatch.context() as mp: + mp.setattr("builtins.open", lambda *a, **k: causeOSError) + manus = GuiManuscript(nwGUI) + manus.show() + manus.loadContent() + assert manus.docPreview.toPlainText().strip() == "" + manus.close() + + # Finish + # qtbot.stop() + +# END Test testManuscript_Init + + +@pytest.mark.gui +def testManuscript_Builds(qtbot: QtBot, nwGUI: GuiMain, projPath: Path): + """Test the handling of builds in the GuiManuscript dialog.""" + buildTestProject(nwGUI, projPath) + nwGUI.openProject(projPath) + + manus = GuiManuscript(nwGUI) + manus.show() + manus.loadContent() + + # Delete the default build + manus.buildList.clearSelection() + manus.buildList.setCurrentRow(0) + manus.tbDel.click() + assert manus.buildList.count() == 0 + + # Create a new build + manus.tbAdd.click() + bSettings = getGuiItem("GuiBuildSettings") + assert isinstance(bSettings, GuiBuildSettings) + bSettings.editBuildName.setText("Test Build") + build = None + + @pyqtSlot(BuildSettings) + def _testNewSettingsReady(new: BuildSettings): + nonlocal build + build = new + + with qtbot.waitSignal(bSettings.newSettingsReady, timeout=5000): + bSettings.newSettingsReady.connect(_testNewSettingsReady) + bSettings.dlgButtons.button(QDialogButtonBox.Save).click() + + assert isinstance(build, BuildSettings) + assert build.name == "Test Build" + assert manus.buildList.count() == 1 + + # Edit the new build + manus.buildList.clearSelection() + manus.buildList.setCurrentRow(0) + manus.tbEdit.click() + + bSettings = getGuiItem("GuiBuildSettings") + assert isinstance(bSettings, GuiBuildSettings) + build = None + + with qtbot.waitSignal(bSettings.newSettingsReady, timeout=5000): + bSettings.newSettingsReady.connect(_testNewSettingsReady) + bSettings.dlgButtons.button(QDialogButtonBox.Apply).click() # Should leave the dialog open + + assert isinstance(build, BuildSettings) + assert build.name == "Test Build" + assert manus.buildList.count() == 1 + + # Close the dialog should also close the child dialogs + manus.btnClose.click() + if isinstance(bSettings, GuiBuildSettings): + # May have been garbage collected, but if it isn't, it should be hidden + assert bSettings.isVisible() is False + + # Finish + # qtbot.stop() + +# END Test testManuscript_Builds + + +@pytest.mark.gui +def testManuscript_Features(monkeypatch, qtbot: QtBot, nwGUI: GuiMain, projPath: Path): + """Test other featurs of the GuiManuscript dialog.""" + buildTestProject(nwGUI, projPath) + nwGUI.openProject(projPath) + + manus = GuiManuscript(nwGUI) + manus.show() + manus.loadContent() + + cacheFile = CONFIG.dataPath("cache") / f"build_{nwGUI.theProject.data.uuid}.json" + manus.buildList.setCurrentRow(0) + build = manus._getSelectedBuild() + assert isinstance(build, BuildSettings) + + # Previews + # ======== + + # No build selected + manus.buildList.clearSelection() + manus.btnPreview.click() + qtbot.wait(200) # Should be enough to run the build + assert manus.docPreview.toPlainText().strip() == "" + assert cacheFile.exists() is False + + # Preview the first, but fail to save cache + manus.buildList.setCurrentRow(0) + with monkeypatch.context() as mp: + mp.setattr("builtins.open", lambda *a, **k: causeOSError) + with qtbot.waitSignal(manus.docPreview.document().contentsChanged): + manus.btnPreview.click() + assert cacheFile.exists() is False + + # Preview again, and allow cache file to be created + manus.buildList.setCurrentRow(0) + with qtbot.waitSignal(manus.docPreview.document().contentsChanged): + manus.btnPreview.click() + assert manus.docPreview.toPlainText().strip() != "" + assert cacheFile.exists() is True + + # Toggle justify + assert manus.docPreview.document().defaultTextOption().alignment() == Qt.AlignAbsolute + manus.docPreview.setJustify(True) + assert manus.docPreview.document().defaultTextOption().alignment() == Qt.AlignJustify + manus.docPreview.setJustify(False) + assert manus.docPreview.document().defaultTextOption().alignment() == Qt.AlignAbsolute + + # Tests are too fast to trigger this one, so we trigger it manually to ensure it isn't failing + manus.docPreview._hideProgress() + + # Builds + # ====== + + build._changed = True + manus.buildList.clearSelection() + with monkeypatch.context() as mp: + mp.setattr("novelwriter.tools.manusbuild.GuiManuscriptBuild.exec_", lambda *a: None) + + # With no selection, no dialog should be created + manus.btnBuild.click() + for obj in manus.children(): + assert not isinstance(obj, GuiManuscriptBuild) + + # With a selection, there should be one + manus.buildList.setCurrentRow(0) + manus.btnBuild.click() + for obj in manus.children(): + if isinstance(obj, GuiManuscriptBuild): + obj.close() + break + else: + assert False + + # Print Preview + # ============= + + with monkeypatch.context() as mp: + mp.setattr(QPrintPreviewDialog, "exec_", lambda *a: None) + manus.btnPrint.click() + for obj in manus.children(): + if isinstance(obj, QPrintPreviewDialog): + obj.show() + obj.close() + break + else: + assert False + + # Finish + manus.close() + # qtbot.stop() + +# END Test testManuscript_Features diff --git a/tests/tools.py b/tests/tools.py index 12b6624c9..e07912ae0 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -112,32 +112,28 @@ def cmpFiles( return not diffFound -def getGuiItem(theName): - """Returns a QtWidget based on its objectName. - """ +def getGuiItem(name: str): + """Returns a QtWidget based on its objectName.""" for qWidget in qApp.topLevelWidgets(): - if qWidget.objectName() == theName: + if qWidget.objectName() == name: return qWidget return None -def readFile(fileName): - """Returns the content of a file as a string. - """ +def readFile(fileName: str | Path): + """Returns the content of a file as a string.""" with open(fileName, mode="r", encoding="utf-8") as inFile: return inFile.read() -def writeFile(fileName, fileData): - """Write the contents of a string to a file. - """ +def writeFile(fileName: str | Path, fileData: str): + """Write the contents of a string to a file.""" with open(fileName, mode="w", encoding="utf-8") as outFile: outFile.write(fileData) -def cleanProject(path): - """Delete all generated files in a project. - """ +def cleanProject(path: str | Path): + """Delete all generated files in a project.""" path = Path(path) cacheDir = path / "cache" if cacheDir.is_dir():