""" UmbraEdit - Main GUI Windows Tests ==================================== This file is a part of UmbraEdit. Copyright (C) 2020 Veronica Berglyd Olsen and UmbraEdit contributors. 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 . """ # noqa from __future__ import annotations import shutil from pathlib import Path from shutil import copyfile from typing import Any from unittest.mock import MagicMock, Mock import pytest from PyQt6.QtCore import QRect, QSize, Qt from PyQt6.QtGui import QCloseEvent, QGuiApplication, QPalette, QStyleHints from PyQt6.QtWidgets import QApplication, QFileDialog, QInputDialog, QWidget from pytestqt.qtbot import QtBot from umbraedit import CONFIG, SHARED, __hexversion__ from umbraedit.common import jsonEncode from umbraedit.config import DEF_GUI_DARK, DEF_GUI_LIGHT from umbraedit.constants import nwFiles from umbraedit.core.project import NWProject from umbraedit.dialogs.editlabel import GuiEditLabel from umbraedit.dialogs.preferences import GuiNeedsUpdate from umbraedit.editor.editor import GuiDocEditor from umbraedit.enum import ( nwChange, nwDocAction, nwDocMode, nwFocus, nwItemClass, nwItemType, nwState, nwTheme, nwView, nwVimMode, ) from umbraedit.gui.noveltree import GuiNovelView from umbraedit.gui.projtree import GuiProjectTree, GuiProjectView from umbraedit.guimain import GuiMain from umbraedit.manuscript.manuscript import GuiManuscript from umbraedit.shared import _GuiAlert from umbraedit.tools.welcome import GuiWelcome from umbraedit.tools.writingstats import GuiWritingStats from umbraedit.types import ( QtKeyBackspace, QtKeyEnter, QtKeyEscape, QtKeyLeft, QtKeyReturn, QtKeyRight, QtModCtrl, QtModShift, QtScrollAlwaysOff, ) from tests.helpers import NWD_IGNORE, XML_IGNORE, C, MockRnd, TstPaths, buildTestProject, cmpFiles from tests.mocked import causeOSError KEY_DELAY = 1 @pytest.mark.gui def testGuiMain_ProjectBlocker(caplog: pytest.LogCaptureFixture, nwGUI: GuiMain): """Test the blocking of features when there's no project open.""" # Test no-project blocking assert nwGUI.closeProject() is True assert nwGUI.saveProject() is False assert nwGUI.docPanes.openDocument(None) is False caplog.clear() nwGUI.docPanes.viewDocument(None) assert "No project open" in caplog.text assert nwGUI.docViewer.docHandle is None caplog.clear() nwGUI.docPanes.importDocument() assert "No project open" in caplog.text # These should all just do nothing and not raise nwGUI.docPanes.closeDocument() nwGUI.rebuildIndex() nwGUI.openSelectedItem() nwGUI.docPanes.openNextDocument(C.hSceneDoc, False, True) nwGUI._autoSaveProject() nwGUI._runGarbageCollect() nwGUI.showProjectSettingsDialog() nwGUI.showBuildManuscriptDialog() nwGUI.showProjectWordListDialog() nwGUI.showWritingStatsDialog() @pytest.mark.gui def testGuiMain_Launch( qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path, fncPath: Path, ): """Test the handling of launch tasks.""" monkeypatch.setattr(GuiWelcome, "exec", lambda *a: None) CONFIG.lastNotes = "0x0" buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) # Open Lipsum project nwGUI.postLaunchTasks(str(projPath)) assert SHARED.hasProject is True nwGUI.closeProject() assert SHARED.hasProject is False # Launch tasks still run fine without a QApplication instance to connect to with monkeypatch.context() as mp: mp.setattr(QApplication, "instance", lambda: None) nwGUI.postLaunchTasks(None) # Open as if called from Welcome nwGUI._openProjectFromWelcome(projPath) assert SHARED.hasProject is True nwGUI.closeProject() assert SHARED.hasProject is False # Open as if called from Welcome, invalid path with monkeypatch.context() as mp: mp.setattr(nwGUI, "showWelcomeDialog", lambda *a: None) nwGUI._openProjectFromWelcome(None) # type: ignore assert SHARED.hasProject is False # Project open fails with monkeypatch.context() as mp: mp.setattr(SHARED, "openProject", lambda *a: False) assert nwGUI.openProject(projPath) is False # Handle locked project with monkeypatch.context() as mp: mp.setattr(SHARED, "openProject", lambda *a, **k: False) SHARED._lockedBy = ["a", "b", "c", "d"] assert nwGUI.openProject(projPath) is False SHARED._lockedBy = None assert nwGUI.openProject(projPath) is True nwGUI.closeProject() # Opening a project with no documents at all leaves the editor and # viewer empty emptyPath = fncPath / "empty" emptyPath.mkdir() project = SHARED.project project.storage.createNewProject(emptyPath) project.setDefaultStatusImport() project.data.setName("Empty") project.newRoot(nwItemClass.NOVEL) project.session.startSession() project.saveProject() project.closeProject(0.0) assert nwGUI.openProject(emptyPath) is True assert nwGUI.docEditor.docHandle is None assert nwGUI.docViewer.docHandle is None nwGUI.closeProject() # A broken index caused by a version upgrade does not show the # "index is broken" warning, but still rebuilds the index assert nwGUI.openProject(projPath) is True nwGUI.closeProject() warnMock = Mock() rebuildMock = Mock() with monkeypatch.context() as mp: mp.setattr(type(SHARED.project.index), "indexRebuild", property(lambda self: True)) mp.setattr(type(SHARED.project.index), "indexUpgrade", property(lambda self: True)) mp.setattr(SHARED, "warn", warnMock) mp.setattr(nwGUI, "rebuildIndex", rebuildMock) assert nwGUI.openProject(projPath) is True assert not any("index is broken" in str(call) for call in warnMock.call_args_list) assert rebuildMock.called is True nwGUI.closeProject() # Check that closes can be blocked with monkeypatch.context() as mp: mp.setattr(_GuiAlert, "finalState", False) assert nwGUI.openProject(projPath) is True assert nwGUI.closeMain() is False nwGUI.closeProject() # Check that latest release info is set assert CONFIG.lastNotes != "0x0" # Set some config error CONFIG._hasError = True CONFIG._errData.append("Foo") # Check that project open dialog launches nwGUI.postLaunchTasks(None) qtbot.waitUntil(lambda: SHARED.findTopLevelWidget(GuiWelcome) is not None, timeout=1000) welcome = SHARED.findTopLevelWidget(GuiWelcome) assert isinstance(welcome, GuiWelcome) welcome.show() welcome.close() # Config errors should be cleared assert SHARED.lastAlert == ["Foo"] assert CONFIG._hasError is False # The welcome dialog isn't repositioned when moveMainWin is disabled CONFIG.moveMainWin = False nwGUI.showWelcomeDialog() # Window sizing tolerates an invalid size list, and skips moving the # window when the size already matches the available screen nwGUI._setWindowSize([100]) # Wrong length, should not raise mockScreen = MagicMock() mockScreen.availableSize.return_value = QSize(1000, 700) mockScreen.geometry.return_value = QRect(0, 0, 1000, 700) with monkeypatch.context() as mp: mp.setattr(type(SHARED), "mainScreen", property(lambda self: mockScreen)) CONFIG.moveMainWin = False nwGUI._setWindowSize([1000, 700]) # qtbot.stop() @pytest.mark.gui def testGuiMain_IndexRebuild(qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path): """Test that a genuinely invalid index cache, as opposed to one from a version upgrade, triggers the "index is broken" warning as well as a rebuild of the index. """ buildTestProject(NWProject(), projPath) indexFile = projPath / "meta" / nwFiles.INDEX_FILE assert indexFile.is_file() # Corrupt the cached index file on disk indexFile.write_text("{not valid json", encoding="utf-8") warnMock = Mock() rebuildMock = Mock() with monkeypatch.context() as mp: mp.setattr(SHARED, "warn", warnMock) mp.setattr(nwGUI, "rebuildIndex", rebuildMock) assert nwGUI.openProject(projPath) is True assert SHARED.project.index.indexUpgrade is False assert any("index is broken" in str(call) for call in warnMock.call_args_list) assert rebuildMock.called is True nwGUI.closeProject() @pytest.mark.gui def testGuiMain_ProjectTreeItems( qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd, ): """Test handling of project tree items based on GUI focus states.""" buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) nwGUI.rebuildIndex() # A project built off-GUI has no index cache to load nwGUI.docPanes.closeDocument() # Opening the project auto-restores a document sHandle = "000000000000f" nwGUI.openSelectedItem() assert nwGUI.docEditor.docHandle is None # Project Tree has focus nwGUI.changeView(nwView.PROJECT) nwGUI._switchFocus(nwFocus.TREE) nwGUI.projStack.setCurrentIndex(0) with monkeypatch.context() as mp: mp.setattr(GuiProjectTree, "hasFocus", lambda *a: True) mp.setattr(GuiNovelView, "treeHasFocus", lambda *a: False) assert nwGUI.docEditor.docHandle is None nwGUI.projView.projTree.setSelectedHandle(sHandle) nwGUI.projView.projTree.openSelectedItem() assert nwGUI.docEditor.docHandle == sHandle nwGUI.docPanes.closeDocument() # A selected item that isn't a file is ignored nwGUI.projView.projTree.setSelectedHandle(C.hChapterDir) nwGUI.openSelectedItem() assert nwGUI.docEditor.docHandle is None # Novel Tree has focus nwGUI.changeView(nwView.NOVEL) with monkeypatch.context() as mp: mp.setattr(GuiProjectView, "treeHasFocus", lambda *a: False) mp.setattr(GuiNovelView, "treeHasFocus", lambda *a: True) assert nwGUI.docEditor.docHandle is None model = nwGUI.novelView.novelTree._getModel() assert model is not None # No selection is ignored nwGUI.novelView.novelTree.setCurrentIndex(model.createIndex(9999, 0)) nwGUI.novelView.novelTree.openSelectedItem() assert nwGUI.docEditor.docHandle is None nwGUI.novelView.novelTree.setCurrentIndex(model.createIndex(2, 0)) nwGUI.novelView.novelTree.openSelectedItem() assert nwGUI.docEditor.docHandle == sHandle nwGUI.docPanes.closeDocument() # The main GUI dispatcher also routes to the novel tree nwGUI.openSelectedItem() assert nwGUI.docEditor.docHandle == sHandle nwGUI.docPanes.closeDocument() # ... and to viewing the document when Shift is held with monkeypatch.context() as mpShift: mpShift.setattr(QApplication, "keyboardModifiers", lambda: QtModShift) nwGUI.openSelectedItem() assert nwGUI.docViewer.docHandle == sHandle nwGUI.docPanes.closeDocViewer() # Internal open-document slots ignore a missing or invalid handle nwGUI.docPanes.openDocumentInMode("", nwDocMode.EDIT, "", True) assert nwGUI.docEditor.docHandle is None nwGUI.docPanes.openDocumentSelection(C.hInvalid, 0, 3, True) assert nwGUI.docEditor.docHandle is None # qtbot.stop() @pytest.mark.gui def testGuiMain_UpdateTheme(qtbot: QtBot, nwGUI: GuiMain): """Test updating the theme in the GUI.""" theme = SHARED.theme CONFIG.themeMode = nwTheme.DARK CONFIG.darkTheme = DEF_GUI_DARK CONFIG.lightTheme = DEF_GUI_LIGHT theme.loadTheme() assert theme.isDarkTheme is True nwGUI._processConfigChanges(GuiNeedsUpdate(False, True, False, False, False, False, False, False, False)) nwGUI._processConfigChanges(GuiNeedsUpdate(True, True, True, True, True, True, True, True, True)) # Check editor syntax syntax = SHARED.theme.syntaxTheme assert nwGUI.docEditor.palette().color(QPalette.ColorRole.Window) == syntax.back assert nwGUI.docEditor.docHeader.palette().color(QPalette.ColorRole.Window) == syntax.back assert nwGUI.docViewer.palette().color(QPalette.ColorRole.Window) == syntax.back assert nwGUI.docViewer.docHeader.palette().color(QPalette.ColorRole.Window) == syntax.back # Update by check CONFIG.themeMode = nwTheme.LIGHT nwGUI.checkThemeUpdate() assert theme.isDarkTheme is False # Checking again without any further change does nothing nwGUI.checkThemeUpdate() assert theme.isDarkTheme is False # qtbot.stop() @pytest.mark.gui @pytest.mark.skipif(not CONFIG.checkMinQtVersion(0x060500), reason="Requires Qt 6.5+") def testGuiMain_OSThemeChangeAndClose(qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path): """Test that the OS colour scheme change signal reaches the theme loader via the connected slot, that isDesktopDarkMode falls back to the palette check when there is no styleHints object, and that the close event handles both a blocked close and a missing theme hints object. """ buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) CONFIG.themeMode = nwTheme.AUTO CONFIG.darkTheme = DEF_GUI_DARK CONFIG.lightTheme = DEF_GUI_LIGHT # Emitting the real OS signal reaches the connected slot, which # feeds the explicit colour scheme through to the theme loader. The # desktop hint is mocked to agree, since checkThemeUpdate's own # refreshThemeColors() call reloads the theme again without the # explicit scheme, falling back to the live desktop hint. assert nwGUI._themeHints is not None with monkeypatch.context() as mp: mp.setattr(QStyleHints, "colorScheme", lambda *a: Qt.ColorScheme.Dark) nwGUI._themeHints.colorSchemeChanged.emit(Qt.ColorScheme.Dark) assert SHARED.theme.isDarkTheme is True with monkeypatch.context() as mp: mp.setattr(QStyleHints, "colorScheme", lambda *a: Qt.ColorScheme.Light) nwGUI._themeHints.colorSchemeChanged.emit(Qt.ColorScheme.Light) assert SHARED.theme.isDarkTheme is False # With no styleHints object at all, isDesktopDarkMode falls back to # the palette check instead of raising with monkeypatch.context() as mp: mp.setattr(QGuiApplication, "styleHints", staticmethod(lambda: None)) SHARED.theme.isDesktopDarkMode() # A blocked close ignores the event and leaves the hints connected with monkeypatch.context() as mp: mp.setattr(_GuiAlert, "finalState", False) event = QCloseEvent() nwGUI.closeEvent(event) assert event.isAccepted() is False # The theme hint object must be explicitly disconnected, since it is # connected to a singleton and will therefore bleed over into other tests nwGUI._themeHints.colorSchemeChanged.disconnect(nwGUI._themeChangedSlot) nwGUI._themeHints = None event = QCloseEvent() nwGUI.closeEvent(event) assert event.isAccepted() is True @pytest.mark.gui def testGuiMain_ProcessConfigChanges_LightweightSettings( qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd, ): """Test the lightweight settings path of _processConfigChanges, which must refresh the document headers on every call, but must only reset Vim mode when the Vim setting itself changed (issue: an unrelated preferences save must not silently drop the user out of Insert mode). """ buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) assert nwGUI.docPanes.openDocument(C.hSceneDoc) nwGUI.docPanes.viewDocument(C.hSceneDoc) assert nwGUI.docViewer.docHandle == C.hSceneDoc docEditor = nwGUI.docEditor docViewer = nwGUI.docViewer CONFIG.vimMode = True docEditor.initSettings(updateVimMode=True) vimMode = docEditor._vimMode assert vimMode is not None vimMode._vim.setMode(nwVimMode.INSERT) # Clear the headers so we can tell if they were refreshed docEditor.docHeader._docHandle = None docViewer.docHeader._docHandle = None # A save where nothing relevant changed, and Vim was not touched, # must still refresh the headers, but must not reset Vim mode noChange = GuiNeedsUpdate( restart=False, tree=False, theme=False, syntax=False, spelling=False, vim=False, editor=False, viewer=False, viewport=False, ) nwGUI._processConfigChanges(noChange) assert docEditor.docHeader._docHandle == C.hSceneDoc assert docViewer.docHeader._docHandle == C.hSceneDoc assert vimMode._vim.mode == nwVimMode.INSERT # A save where the Vim setting changed must reset the mode to Normal vimChanged = GuiNeedsUpdate( restart=False, tree=False, theme=False, syntax=False, spelling=False, vim=True, editor=False, viewer=False, viewport=False, ) nwGUI._processConfigChanges(vimChanged) assert vimMode._vim.mode == nwVimMode.NORMAL # A save that fully reinitialises the editor and viewer must not # additionally run the lightweight path vimMode._vim.setMode(nwVimMode.INSERT) fullReinit = GuiNeedsUpdate( restart=False, tree=False, theme=False, syntax=False, spelling=False, vim=False, editor=True, viewer=True, viewport=False, ) nwGUI._processConfigChanges(fullReinit) assert vimMode._vim.mode == nwVimMode.NORMAL # A viewport-only change must reinitialise the viewport on both the # editor and viewer even though neither is otherwise reinitialised CONFIG.hideVScroll = True CONFIG.hideHScroll = True viewportOnly = GuiNeedsUpdate( restart=False, tree=False, theme=False, syntax=False, spelling=False, vim=False, editor=False, viewer=False, viewport=True, ) nwGUI._processConfigChanges(viewportOnly) assert docEditor.verticalScrollBarPolicy() == QtScrollAlwaysOff assert docEditor.horizontalScrollBarPolicy() == QtScrollAlwaysOff assert docViewer.verticalScrollBarPolicy() == QtScrollAlwaysOff assert docViewer.horizontalScrollBarPolicy() == QtScrollAlwaysOff # qtbot.stop() @pytest.mark.gui def testGuiMain_Editing( qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path, tstPaths: TstPaths, mockRnd: MockRnd, ): """Test the document editor.""" monkeypatch.setattr(GuiProjectTree, "hasFocus", lambda *a: True) monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True) monkeypatch.setattr(QInputDialog, "getText", lambda *a, text: (text, True)) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) # Create new, save, close project buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) assert nwGUI.saveProject() assert nwGUI.closeProject() assert len(SHARED.project.tree) == 0 assert len(SHARED.project.tree._items) == 0 assert len(SHARED.project.tree._nodes) == 0 assert SHARED.project.data.name == "" assert SHARED.project.data.author == "" assert SHARED.project.data.spellCheck is False # Check the files projFile = projPath / "nwProject.nwx" testFile = tstPaths.outDir / "guiEditor_Main_Initial_nwProject.nwx" compFile = tstPaths.refDir / "guiEditor_Main_Initial_nwProject.nwx" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, ignStart=XML_IGNORE) # Re-open project assert nwGUI.openProject(projPath) # Check that we loaded the data assert len(SHARED.project.tree) == 9 assert SHARED.project.tree.model.root.childCount() == 5 assert SHARED.project.tree.trash is not None # Created automatically assert SHARED.project.data.name == "New Project" assert SHARED.project.data.author == "Jane Doe" assert SHARED.project.data.spellCheck is False # Check that tree items have been created assert SHARED.project.tree[C.hNovelRoot] is not None assert SHARED.project.tree[C.hPlotRoot] is not None assert SHARED.project.tree[C.hCharRoot] is not None assert SHARED.project.tree[C.hWorldRoot] is not None assert SHARED.project.tree[C.hTitlePage] is not None assert SHARED.project.tree[C.hChapterDir] is not None assert SHARED.project.tree[C.hChapterDoc] is not None assert SHARED.project.tree[C.hSceneDoc] is not None nwGUI.mainMenu.aSpellCheck.setChecked(True) nwGUI.mainMenu._toggleSpellCheck() # Change some settings CONFIG.hideHScroll = True CONFIG.hideVScroll = True CONFIG.autoScroll = False # Add a Character File nwGUI.changeView(nwView.PROJECT) nwGUI.projView.projTree.expandAll() nwGUI.projView.projTree.clearSelection() nwGUI.projView.projTree.setSelectedHandle(C.hCharRoot) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True) nwGUI.projView.projTree.expandAll() nwGUI.openSelectedItem() # Text Editor # =========== # Syntax Highlighting CONFIG.dialogStyle = 3 CONFIG.dialogLine = "\u2013" CONFIG.altDialogOpen = "<|" CONFIG.altDialogClose = "|>" docEditor = nwGUI.docEditor docEditor._qDocument.syntaxHighlighter.initHighlighter() # Type something into the document nwGUI.docEditor.setFocus() qtbot.keyClick(docEditor, "a", modifier=QtModCtrl, delay=KEY_DELAY) for c in "# Jane Doe": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "@tag: Jane": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "This is a file about Jane.": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) # Add a Plot File nwGUI.projView.projTree.expandAll() nwGUI.projView.projTree.clearSelection() nwGUI.projView.projTree.setSelectedHandle(C.hPlotRoot) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True) nwGUI.projView.projTree.expandAll() nwGUI.openSelectedItem() # Type something into the document nwGUI.docEditor.setFocus() qtbot.keyClick(docEditor, "a", modifier=QtModCtrl, delay=KEY_DELAY) for c in "# Main Plot": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "@tag: MainPlot": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "This is a file [i]detailing[/i] the main plot.": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) # Add a World File nwGUI.projView.projTree.expandAll() nwGUI.projView.projTree.clearSelection() nwGUI.projView.projTree.setSelectedHandle(C.hWorldRoot) nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, None, isNote=True) nwGUI.projView.projTree.expandAll() nwGUI.openSelectedItem() # Add Some Text docEditor.replaceText("Hello World!") assert docEditor.getText() == "Hello World!" docEditor.replaceText("") # Type something into the document nwGUI.docEditor.setFocus() qtbot.keyClick(docEditor, "a", modifier=QtModCtrl, delay=KEY_DELAY) for c in "# Main Location": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "@tag: Home": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "This is a file describing Jane's home.": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) # Trigger autosaves before making more changes nwGUI._autoSaveDocument() nwGUI._autoSaveProject() # Select the 'New Scene' file nwGUI.projView.projTree.expandAll() nwGUI.projView.projTree.clearSelection() nwGUI.projView.projTree.setSelectedHandle(C.hSceneDoc) nwGUI.openSelectedItem() # Type something into the document nwGUI.docEditor.setFocus() qtbot.keyClick(docEditor, "a", modifier=QtModCtrl, delay=KEY_DELAY) for c in "# Novel": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "## Chapter": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "@pov: Jane": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "@plot: MainPlot": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "### Scene": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "% How about a comment?": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "@pov: Jane": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "@plot: MainPlot": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "@location: Home": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "#### Some Section": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "@char: Jane": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "This is a paragraph of nonsense text.": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) # Don't allow Shift+Enter to insert a line separator (issue #1150) for c in "This is another paragraph": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyEnter, modifier=QtModShift, delay=KEY_DELAY) for c in "with a line separator in it.": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) # Auto-Replace # ============ for c in ( "This is another paragraph of much longer nonsense text. It is in fact 1 very very NONSENSICAL nonsense text! " ): qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "We can also try replacing \"quotes\", even single 'quotes' are replaced. ": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "Isn't that nice? ": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "We can hyphen-ate, make dashes -- and even longer dashes --- if we want. ": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "We can even go on to a ---- hotizontal bar. ": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "Ellipsis? Not a problem either ... ": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "How about three hyphens - -": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyLeft, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyBackspace, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyRight, delay=KEY_DELAY) for c in "- for long dash? It works too. ": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) for c in "Even four hyphens - - -": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyLeft, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyBackspace, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyLeft, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyBackspace, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyRight, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyRight, delay=KEY_DELAY) for c in "- for a horizontal works!": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) # Dialogue # ======== for c in '"Full line double quoted text."': qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "'Full line single quoted text.'": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) # Insert spaces before and after quotes CONFIG.fmtPadBefore = "\u201d" CONFIG.fmtPadAfter = "\u201c" docEditor.initEditor() for c in 'Some "double quoted text with spaces padded". Ok?': qtbot.keyClick(docEditor, c, delay=KEY_DELAY) # Check that we can insert a line break in the text without # triggering the padding (Issue #2586) qtbot.keyClick(docEditor, QtKeyLeft, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyLeft, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyLeft, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyRight, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyRight, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyRight, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) CONFIG.fmtPadBefore = "" CONFIG.fmtPadAfter = "" docEditor.initEditor() # Dialogue Line for c in "-- Hi, I am a character speaking.": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) # Narrator Break CONFIG.narratorBreak = "\u2013" docEditor = nwGUI.docEditor docEditor._qDocument.syntaxHighlighter.initHighlighter() for c in "-- Hi, I am also a character speaking, -- said another character. -- How are you?": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) # Special Formatting # ================== # Insert spaces before colon, but ignore tags CONFIG.fmtPadBefore = ":" docEditor.initEditor() for c in "@object: NoSpaceAdded": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "% synopsis: Space before this is OK.": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "%Footnote.abc: A simple footnote.": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "Add space before this colon: See?": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "But don't add a double space : See?": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) CONFIG.fmtPadBefore = "" docEditor.initEditor() # Indent and Align # ================ nwGUI._switchFocus(nwFocus.DOCUMENT) for c in '\t"Tab-indented text"': qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in '>"Paragraph-indented text"': qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in '>>"Right-aligned text"': qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in "\t'Tab-indented text'": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in ">'Paragraph-indented text'": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) for c in ">>'Right-aligned text'": qtbot.keyClick(docEditor, c, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY) docEditor._updateDocCounts(0, 0, 0) # Check Files # =========== # Save the document assert docEditor.docChanged nwGUI.docPanes.saveDocument() assert docEditor.docChanged is False nwGUI.docPanes.forceSaveDocument() assert docEditor.docChanged is False nwGUI.rebuildIndex() # Open and view the edited document nwGUI.docViewer.setFocus() assert nwGUI.docPanes.openDocument(C.hSceneDoc) nwGUI.docPanes.viewDocument(C.hSceneDoc) assert nwGUI.docViewer.docHandle == C.hSceneDoc assert nwGUI.saveProject() nwGUI.docPanes.closeViewerPanel() assert nwGUI.docPanes.splitView.isVisible() is False # Check the files projFile = projPath / "nwProject.nwx" testFile = tstPaths.outDir / "guiEditor_Main_Final_nwProject.nwx" compFile = tstPaths.refDir / "guiEditor_Main_Final_nwProject.nwx" copyfile(projFile, testFile) assert cmpFiles(testFile, compFile, ignStart=(*XML_IGNORE, " 0 assert abs(edWidth - vwWidth) <= 1 # Closing the viewer panel doesn't restore cursor visibility either, # if the cursor wasn't visible to begin with with monkeypatch.context() as mp: mp.setattr(nwGUI.docEditor, "cursorIsVisible", lambda: False) nwGUI.docPanes.closeViewerPanel() # The editor takes back the full width assert nwGUI.docPanes.splitMain.sizes()[1] == 0 # Reopening the viewer always restores the even split nwGUI.docPanes.viewDocument(C.hSceneDoc) nwGUI.docPanes.splitMain.setSizes([3, 1]) nwGUI.docPanes.closeViewerPanel() nwGUI.docPanes.viewDocument(C.hSceneDoc) edWidth, vwWidth = nwGUI.docPanes.splitMain.sizes() assert abs(edWidth - vwWidth) <= 1 nwGUI.docPanes.closeViewerPanel() assert nwGUI.docPanes.splitView.isVisible() is False # Reloading the viewer only saves the editor first if the two # panels show the same document nwGUI.docPanes.openDocument(C.hChapterDoc) nwGUI.docPanes.viewDocument(C.hSceneDoc) assert nwGUI.docEditor.docHandle != nwGUI.docViewer.docHandle nwGUI.docPanes._reloadViewer() # Should not raise, and skip saving the editor # qtbot.stop() @pytest.mark.gui def testGuiMain_FocusMode( qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd, ): """Test focus mode in the main window.""" buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) nwGUI.docPanes.closeDocument() # Opening the project auto-restores a document assert SHARED.focusMode is False # Header heights don't change when a document is opened editHeight = nwGUI.docEditor.docHeader.sizeHint().height() viewHeight = nwGUI.docViewer.docHeader.sizeHint().height() # No document open, so not allowing focus mode nwGUI.toggleFocusMode() assert nwGUI.treePane.isVisible() is True assert nwGUI.mainStatus.isVisible() is True assert nwGUI.mainMenu.isVisible() is True assert nwGUI.sideBar.isVisible() is True # Open a file only in the editor, with the viewer untouched and the # cursor hidden: toggling focus mode doesn't touch the viewer panel # or try to restore cursor visibility assert nwGUI.docPanes.openDocument(C.hSceneDoc) assert nwGUI.docViewer.docHandle is None assert nwGUI.docPanes.splitView.isVisible() is False with monkeypatch.context() as mp: mp.setattr(nwGUI.docEditor, "cursorIsVisible", lambda: False) nwGUI.toggleFocusMode() assert SHARED.focusMode is True nwGUI.toggleFocusMode() assert SHARED.focusMode is False # Open a file in editor and viewer assert nwGUI.docPanes.openDocument(C.hSceneDoc) nwGUI.docPanes.viewDocument(C.hSceneDoc) assert nwGUI.docViewer.docHandle == C.hSceneDoc assert nwGUI.docEditor.docHeader.sizeHint().height() == editHeight assert nwGUI.docViewer.docHeader.sizeHint().height() == viewHeight # Enable focus mode nwGUI.toggleFocusMode() assert SHARED.focusMode is True assert nwGUI.treePane.isVisible() is False assert nwGUI.mainStatus.isVisible() is False assert nwGUI.mainMenu.isVisible() is False assert nwGUI.sideBar.isVisible() is False assert nwGUI.docPanes.splitView.isVisible() is False # Disable focus mode nwGUI.toggleFocusMode() assert SHARED.focusMode is False assert nwGUI.treePane.isVisible() is True assert nwGUI.mainStatus.isVisible() is True assert nwGUI.mainMenu.isVisible() is True assert nwGUI.sideBar.isVisible() is True assert nwGUI.docPanes.splitView.isVisible() is True # Clean header hides most header content in focus mode docHeader = nwGUI.docEditor.docHeader CONFIG.cleanFocusHeader = True nwGUI.toggleFocusMode() assert SHARED.focusMode is True assert docHeader.tbButton.isVisible() is True assert docHeader.outlineButton.isVisible() is False assert docHeader.searchButton.isVisible() is False assert docHeader.itemTitle.isVisible() is False assert docHeader.minmaxButton.isVisible() is True assert docHeader.closeButton.isVisible() is False # Item updates, like after a save, don't restore the header content nwGUI.docEditor.onProjectItemChanged(C.hSceneDoc, nwChange.UPDATE) assert docHeader.outlineButton.isVisible() is False assert docHeader.searchButton.isVisible() is False assert docHeader.itemTitle.isVisible() is False assert docHeader.closeButton.isVisible() is False # Leaving focus mode restores the header content nwGUI.toggleFocusMode() assert SHARED.focusMode is False assert docHeader.outlineButton.isVisible() is True assert docHeader.searchButton.isVisible() is True assert docHeader.itemTitle.isVisible() is True assert docHeader.closeButton.isVisible() is True # A hidden footer reserves no space in focus mode docFooter = nwGUI.docEditor.docFooter CONFIG.hideFocusFooter = True assert docFooter.heightHint() > 0 nwGUI.toggleFocusMode() assert docFooter.heightHint() == 0 nwGUI.toggleFocusMode() assert docFooter.heightHint() > 0 # Closing editor disables focus mode nwGUI.toggleFocusMode() assert SHARED.focusMode is True nwGUI.docPanes.closeDocument() assert SHARED.focusMode is False nwGUI.docPanes.openDocument(C.hSceneDoc) # Pressing Escape turns off focus mode. Focus mode puts focus on the # editor itself, which is where GuiDocEditor.keyPressEvent handles it. nwGUI.toggleFocusMode() assert SHARED.focusMode is True assert nwGUI.docEditor.hasFocus() is True qtbot.keyClick(nwGUI.docEditor, QtKeyEscape) assert SHARED.focusMode is False # If search is active, focus is in the search box instead, and Escape # there just closes the search, leaving focus mode untouched nwGUI.toggleFocusMode() assert SHARED.focusMode is True nwGUI.docEditor.beginSearch() assert nwGUI.docEditor.docSearch.searchBox.hasFocus() is True qtbot.keyClick(nwGUI.docEditor.docSearch.searchBox, QtKeyEscape) assert nwGUI.docEditor.searchVisible() is False assert SHARED.focusMode is True # If search is active but focus is in the editor itself rather than # the search box, Escape still closes the search, this time via the # editor's own keyPressEvent rather than the search box's event filter nwGUI.docEditor.beginSearch() nwGUI.docEditor.setFocus() assert nwGUI.docEditor.hasFocus() is True assert nwGUI.docEditor.searchVisible() is True qtbot.keyClick(nwGUI.docEditor, QtKeyEscape) assert nwGUI.docEditor.searchVisible() is False assert SHARED.focusMode is True # With search closed, Vim mode off, and Focus Mode inactive, Escape # does nothing nwGUI.toggleFocusMode() assert nwGUI.docEditor.searchVisible() is False assert CONFIG.vimMode is False assert SHARED.focusMode is False qtbot.keyClick(nwGUI.docEditor, QtKeyEscape) @pytest.mark.gui def testGuiMain_Features( qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd, ): """Test various features of the main window.""" buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) nwGUI.docPanes.closeDocument() # Opening the project auto-restores a document cHandle = SHARED.project.newFile("Jane", C.hCharRoot) newDoc = SHARED.project.storage.getDocument(cHandle) newDoc.writeDocument("# Jane\n\n@tag: Jane\n\n") nwGUI.rebuildIndex() # Full Screen Mode # ================ fullScreen = Qt.WindowState.WindowFullScreen assert nwGUI.windowState() & fullScreen != fullScreen nwGUI.toggleFullScreenMode() assert nwGUI.windowState() & fullScreen == fullScreen nwGUI.toggleFullScreenMode() assert nwGUI.windowState() & fullScreen != fullScreen # SideBar Menu # ============ # Just make sure the custom event handler executes and doesn't fail nwGUI.sideBar.mSettings.show() nwGUI.sideBar.mSettings.hide() # Redirect Tag Open # ================= nwGUI.docPanes.closeDocument() nwGUI.docPanes.closeDocViewer() assert nwGUI.docEditor.docHandle is None assert nwGUI.docViewer.docHandle is None nwGUI.docPanes._followTag("John", nwDocMode.EDIT) # Doesn't exist assert nwGUI.docEditor.docHandle is None assert nwGUI.docViewer.docHandle is None nwGUI.docPanes._followTag("Jane", nwDocMode.EDIT) assert nwGUI.docEditor.docHandle == cHandle assert nwGUI.docViewer.docHandle is None nwGUI.docPanes._followTag("Jane", nwDocMode.VIEW) assert nwGUI.docEditor.docHandle == cHandle assert nwGUI.docViewer.docHandle == cHandle # Errors Handling # =============== # Cannot edit a folder assert nwGUI.docPanes.openDocument(C.hChapterDir) is False # Handle I/O error with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) assert nwGUI.docPanes.openDocument(C.hChapterDoc) is False # Import Document # =============== # Importing into an empty document does not prompt for overwrite # confirmation assert nwGUI.docPanes.openDocument(C.hSceneDoc) nwGUI.docEditor.replaceText("") assert nwGUI.docEditor.isEmpty is True importFile = projPath / "import.txt" importFile.write_text("Imported text.", encoding="utf-8") with monkeypatch.context() as mp: mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(importFile), "")) nwGUI.docPanes.importDocument() assert nwGUI.docEditor.getText() == "Imported text." # Dialogs # ======= # Reopening the manuscript and writing stats dialogs reuses the # existing instance rather than creating a new one nwGUI.showBuildManuscriptDialog() manuscriptDialog = SHARED.findTopLevelWidget(GuiManuscript) assert manuscriptDialog is not None nwGUI.showBuildManuscriptDialog() assert SHARED.findTopLevelWidget(GuiManuscript) is manuscriptDialog manuscriptDialog.close() manuscriptDialog.softDelete() nwGUI.showWritingStatsDialog() statsDialog = SHARED.findTopLevelWidget(GuiWritingStats) assert statsDialog is not None nwGUI.showWritingStatsDialog() assert SHARED.findTopLevelWidget(GuiWritingStats) is statsDialog statsDialog.close() statsDialog.softDelete() # qtbot.stop() @pytest.mark.gui def testGuiMain_CloseMain(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd): """Test closeMain's pane-size and window-state save skips, across a project close/reopen cycle.. """ buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) # closeMain skips saving pane sizes while Focus Mode is active assert nwGUI.docPanes.openDocument(C.hSceneDoc) nwGUI.toggleFocusMode() assert SHARED.focusMode is True assert nwGUI.closeMain() is True # closeMain skips saving the window size while in full screen mode assert nwGUI.openProject(projPath) is True nwGUI.setWindowState(nwGUI.windowState() | Qt.WindowState.WindowFullScreen) assert nwGUI.closeMain() is True @pytest.mark.gui def testGuiMain_ToggleTreePane(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd): """Test toggling the tree pane from the side bar.""" buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) assert nwGUI.docPanes.openDocument(C.hSceneDoc) sideBar = nwGUI.sideBar assert nwGUI.projStack.currentWidget() == nwGUI.projView assert nwGUI.treePane.isHidden() is False # Clicking the active view toggles the pane sideBar.tbProject.click() assert nwGUI.treePane.isHidden() is True sideBar.tbProject.click() assert nwGUI.treePane.isHidden() is False # Switching to another view shows the pane sideBar.tbProject.click() sideBar.tbNovel.click() assert nwGUI.projStack.currentWidget() == nwGUI.novelView assert nwGUI.treePane.isHidden() is False # Other sources don't toggle the pane sideBar.tbNovel.click() nwGUI.changeView(nwView.NOVEL) assert nwGUI.treePane.isHidden() is True # Opening a document doesn't show the pane assert nwGUI.docPanes.openDocument(C.hChapterDoc) assert nwGUI.treePane.isHidden() is True # Returning from story view shows the pane sideBar.tbStory.click() sideBar.tbNovel.click() assert nwGUI.treePane.isHidden() is False # Focus mode restores the hidden state sideBar.tbNovel.click() nwGUI.toggleFocusMode() assert SHARED.focusMode is True nwGUI.toggleFocusMode() assert SHARED.focusMode is False assert nwGUI.treePane.isHidden() is True # Focusing the tree shows the pane nwGUI._switchFocus(nwFocus.TREE) assert nwGUI.projStack.currentWidget() == nwGUI.novelView assert nwGUI.treePane.isHidden() is False # The menu action toggles the pane aToggle = nwGUI.mainMenu.aHideTreePane aToggle.trigger() assert nwGUI.treePane.isHidden() is True aToggle.trigger() assert nwGUI.treePane.isHidden() is False # The menu action does nothing in story view sideBar.tbStory.click() aToggle.trigger() assert nwGUI.treePane.isHidden() is False sideBar.tbNovel.click() # The menu action does nothing in focus mode nwGUI.toggleFocusMode() assert SHARED.focusMode is True aToggle.trigger() assert nwGUI.treePane.isHidden() is True nwGUI.toggleFocusMode() assert SHARED.focusMode is False assert nwGUI.treePane.isHidden() is False # The pane size is not saved when hidden sideBar.tbNovel.click() assert nwGUI.treePane.isHidden() is True CONFIG.mainPanePos = [123, 456] assert nwGUI.closeMain() is True assert CONFIG.mainPanePos == [123, 456] @pytest.mark.gui def testGuiMain_OpenClose( qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path, fncPath: Path, mockRnd: MockRnd, ): """Test opening and closing projects and documents.""" buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) # Check open document and prev/next with no wraparound nwGUI.docPanes.openNextDocument(C.hSceneDoc, False, True) # Backwards assert nwGUI.docEditor._docHandle == C.hChapterDoc nwGUI.docPanes.openNextDocument(C.hChapterDoc, False, False) # Forwards assert nwGUI.docEditor._docHandle == C.hSceneDoc # Check open document and prev/next with wraparound nwGUI.docPanes.openNextDocument(C.hSceneDoc, True, False) # Forwards assert nwGUI.docEditor._docHandle == C.hTitlePage nwGUI.docPanes.openNextDocument(C.hTitlePage, True, True) # Backwards assert nwGUI.docEditor._docHandle == C.hSceneDoc # Check with handle that is not a document, should do nothing nwGUI.docPanes.openDocument(C.hSceneDoc) assert nwGUI.docEditor._docHandle == C.hSceneDoc nwGUI.docPanes.openNextDocument(C.hNovelRoot, True, True) assert nwGUI.docEditor._docHandle == C.hSceneDoc # No wraparound at the very first or last document stays put allDocs = SHARED.project.tree.allDocs() firstDoc = allDocs[0] lastDoc = allDocs[-1] nwGUI.docPanes.openDocument(firstDoc) nwGUI.docPanes.openNextDocument(firstDoc, False, True) # Backwards, no wraparound assert nwGUI.docEditor.docHandle == firstDoc nwGUI.docPanes.openDocument(lastDoc) nwGUI.docPanes.openNextDocument(lastDoc, False, False) # Forwards, no wraparound assert nwGUI.docEditor.docHandle == lastDoc # Autosaving does nothing when there are no changes to save assert nwGUI.saveProject() is True assert SHARED.project.projChanged is False nwGUI._autoSaveProject() assert nwGUI.docEditor.docChanged is False nwGUI._autoSaveDocument() # Open some test documents nwGUI.docPanes.openDocument(C.hSceneDoc) nwGUI.docPanes.viewDocument(C.hTitlePage) # Handle broken index on project open idxData = jsonEncode({"UmbraEdit.meta": {"version": __hexversion__}}) nwGUI.closeProject() idxPath: Path = projPath / "meta" / nwFiles.INDEX_FILE assert idxPath.read_text(encoding="utf-8") != "{}" idxPath.write_text(idxData, encoding="utf-8") nwGUI.openProject(projPath) nwGUI.saveProject() assert idxPath.read_text(encoding="utf-8") != "{}" assert nwGUI.docEditor.docHandle == C.hSceneDoc assert nwGUI.docViewer.docHandle == C.hTitlePage # Block closing assert SHARED.hasProject is True with monkeypatch.context() as mp: mp.setattr(_GuiAlert, "finalState", False) assert nwGUI.openProject(projPath) is False assert SHARED.hasProject is True # Don't open on lockfile question: No lockPath: Path = projPath / nwFiles.PROJ_LOCK lockBack: Path = projPath / f"{nwFiles.PROJ_LOCK}.bak" shutil.copyfile(lockPath, lockBack) nwGUI.closeProject() shutil.copyfile(lockBack, lockPath) with monkeypatch.context() as mp: mp.setattr(_GuiAlert, "finalState", False) assert nwGUI.openProject(projPath) is False assert nwGUI.openProject(projPath) is True # Backup on close backDir = CONFIG.backupPath() / SHARED.project.data.name assert not backDir.exists() CONFIG.backupOnClose = True assert nwGUI.openProject(projPath) is True nwGUI.closeProject() assert backDir.exists() assert len(list(backDir.iterdir())) > 0 # Backing up without prompting first, when askBeforeBackup is disabled CONFIG.askBeforeBackup = False questionMock = Mock() backupMock = Mock() with monkeypatch.context() as mp: mp.setattr(SHARED, "question", questionMock) mp.setattr(type(SHARED.project), "backupProject", backupMock) assert nwGUI.openProject(projPath) is True assert nwGUI.closeProject(isYes=True) is True assert questionMock.called is False assert backupMock.called is True @pytest.mark.gui def testGuiMain_OpenDocument_ReentrancyGuard( qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd, ): """Test that openDocument ignores a re-entrant call made while a document switch is already in progress. This models what happens holding F3 with project-wide loop search enabled: loadText calls QApplication.processEvents while a switch is still in progress, and if that dispatches an already-queued key-repeat event, it can re-trigger another document switch before the first one has finished. """ buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) reentered = [] realProcessEvents = QApplication.processEvents def reentrantProcessEvents(*args: Any, **kwargs: Any) -> None: if not reentered: reentered.append(True) assert nwGUI.docPanes.openDocument(C.hChapterDoc) is False return realProcessEvents(*args, **kwargs) monkeypatch.setattr(QApplication, "processEvents", staticmethod(reentrantProcessEvents)) assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True assert reentered == [True] assert nwGUI.docEditor.docHandle == C.hSceneDoc @pytest.mark.gui def testGuiMain_FocusView( qtbot: QtBot, monkeypatch: pytest.MonkeyPatch, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd, ): """Test switching focus and view of the main window.""" buildTestProject(NWProject(), projPath) nwGUI.openProject(projPath) nwGUI.docPanes.openDocument(C.hSceneDoc) nwGUI.docPanes.viewDocument(C.hSceneDoc) widget = QWidget() qtbot.addWidget(widget) # Toggle Focus # ============ nwGUI.docEditor.setFocus() assert nwGUI.docEditor.anyFocus() # Simulate focus change to viewer nwGUI.docPanes.onFocusChanged(widget, nwGUI.docViewer) assert nwGUI.docEditor.docHeader.itemTitle._state == nwState.INACTIVE assert nwGUI.docViewer.docHeader.itemTitle._state == nwState.NORMAL # Simulate focus change to editor nwGUI.docPanes.onFocusChanged(widget, nwGUI.docEditor) assert nwGUI.docEditor.docHeader.itemTitle._state == nwState.NORMAL assert nwGUI.docViewer.docHeader.itemTitle._state == nwState.INACTIVE # Simulate focus change to an unrelated widget: neither is focused nwGUI.docPanes.onFocusChanged(widget, widget) assert nwGUI.docEditor.docHeader.itemTitle._state == nwState.INACTIVE assert nwGUI.docViewer.docHeader.itemTitle._state == nwState.INACTIVE # Focus Tree # ========== assert nwGUI.projStack.currentWidget() == nwGUI.projView # Switch from editor to project tree nwGUI.docEditor.setFocus() nwGUI._switchFocus(nwFocus.TREE) assert nwGUI.projStack.currentWidget() == nwGUI.projView # Triggering again should switch to novel view nwGUI._switchFocus(nwFocus.TREE) assert nwGUI.projStack.currentWidget() == nwGUI.novelView # Switch from editor to novel view nwGUI.docEditor.setFocus() nwGUI._switchFocus(nwFocus.TREE) assert nwGUI.projStack.currentWidget() == nwGUI.novelView # Triggering again should switch back to project tree nwGUI._switchFocus(nwFocus.TREE) assert nwGUI.projStack.currentWidget() == nwGUI.projView # If in search mode, should default to project tree nwGUI.changeView(nwView.SEARCH) nwGUI._switchFocus(nwFocus.TREE) assert nwGUI.projStack.currentWidget() == nwGUI.projView # Focus Document # ============== nwGUI._switchFocus(nwFocus.TREE) def mockEmitEditorFocus(*a: Any) -> None: nwGUI.docPanes.onFocusChanged(widget, nwGUI.docEditor) def mockEmitViewerFocus(*a: Any) -> None: nwGUI.docPanes.onFocusChanged(widget, nwGUI.docViewer) # Switch to viewer with monkeypatch.context() as mp: mp.setattr(nwGUI.docEditor, "hasFocus", lambda *a: True) mp.setattr(nwGUI.docViewer, "hasFocus", lambda *a: False) mp.setattr(nwGUI.docEditor, "setFocus", mockEmitEditorFocus) mp.setattr(nwGUI.docViewer, "setFocus", mockEmitViewerFocus) nwGUI._switchFocus(nwFocus.DOCUMENT) assert nwGUI.docEditor.docHeader.itemTitle._state == nwState.INACTIVE assert nwGUI.docViewer.docHeader.itemTitle._state == nwState.NORMAL # Call again to switch to editor with monkeypatch.context() as mp: mp.setattr(nwGUI.docEditor, "hasFocus", lambda *a: False) mp.setattr(nwGUI.docViewer, "hasFocus", lambda *a: True) mp.setattr(nwGUI.docEditor, "setFocus", mockEmitEditorFocus) mp.setattr(nwGUI.docViewer, "setFocus", mockEmitViewerFocus) nwGUI._switchFocus(nwFocus.DOCUMENT) assert nwGUI.docEditor.docHeader.itemTitle._state == nwState.NORMAL assert nwGUI.docViewer.docHeader.itemTitle._state == nwState.INACTIVE # Default to editor with monkeypatch.context() as mp: mp.setattr(nwGUI.docEditor, "hasFocus", lambda *a: False) mp.setattr(nwGUI.docViewer, "hasFocus", lambda *a: False) mp.setattr(nwGUI.docEditor, "setFocus", mockEmitEditorFocus) mp.setattr(nwGUI.docViewer, "setFocus", mockEmitViewerFocus) nwGUI._switchFocus(nwFocus.DOCUMENT) assert nwGUI.docEditor.docHeader.itemTitle._state == nwState.NORMAL assert nwGUI.docViewer.docHeader.itemTitle._state == nwState.INACTIVE # Pass Actions # ============ # Pass to editor with monkeypatch.context() as mp: mp.setattr(nwGUI.docEditor, "hasFocus", lambda *a: True) mp.setattr(nwGUI.docViewer, "hasFocus", lambda *a: False) nwGUI.docPanes.passDocumentAction(nwDocAction.SEL_ALL) assert nwGUI.docEditor.textCursor().hasSelection() is True # Pass to viewer with monkeypatch.context() as mp: mp.setattr(nwGUI.docEditor, "hasFocus", lambda *a: False) mp.setattr(nwGUI.docViewer, "hasFocus", lambda *a: True) nwGUI.docPanes.passDocumentAction(nwDocAction.SEL_ALL) assert nwGUI.docViewer.textCursor().hasSelection() is True # Pass to neither, since neither has focus: nothing changes hadSelection = nwGUI.docEditor.textCursor().hasSelection() with monkeypatch.context() as mp: mp.setattr(nwGUI.docEditor, "hasFocus", lambda *a: False) mp.setattr(nwGUI.docViewer, "hasFocus", lambda *a: False) nwGUI.docPanes.passDocumentAction(nwDocAction.MOVE_TEXT) assert nwGUI.docEditor.textCursor().hasSelection() is hadSelection # qtbot.stop()