Fix branch misses on gui modules, part 3
This commit is contained in:
@@ -434,7 +434,7 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
palette.setColor(QPalette.ColorRole.Text, syntax.text)
|
||||
self.setPalette(palette)
|
||||
|
||||
if viewport := self.viewport():
|
||||
if viewport := self.viewport(): # pragma: no branch
|
||||
palette = viewport.palette()
|
||||
palette.setColor(QPalette.ColorRole.Base, syntax.back)
|
||||
palette.setColor(QPalette.ColorRole.Text, syntax.text)
|
||||
@@ -622,8 +622,7 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
|
||||
if not saveOk:
|
||||
SHARED.error(self.tr("Could not save document."), info=self._nwDocument.getError())
|
||||
|
||||
return False
|
||||
return False
|
||||
|
||||
self.setDocumentChanged(False)
|
||||
self.docTextChanged.emit(self._docHandle, self._lastEdit)
|
||||
@@ -641,7 +640,7 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
|
||||
def ensureCursorVisibleNoCentre(self) -> None:
|
||||
"""Ensure cursor is visible, but don't force it to centre."""
|
||||
if (viewport := self.viewport()) and (vBar := self.verticalScrollBar()):
|
||||
if (viewport := self.viewport()) and (vBar := self.verticalScrollBar()): # pragma: no branch
|
||||
cT = self.cursorRect().top()
|
||||
cB = self.cursorRect().bottom()
|
||||
vH = viewport.height()
|
||||
@@ -769,7 +768,7 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
"""Move the cursor to a given line in the document."""
|
||||
if isinstance(line, int) and line != 0:
|
||||
line = self._qDocument.blockCount() + line if line < 0 else line - 1
|
||||
if block := self._qDocument.findBlockByNumber(line):
|
||||
if (block := self._qDocument.findBlockByNumber(line)).isValid():
|
||||
self.setCursorPosition(block.position())
|
||||
logger.debug("Cursor moved to line %d", line + 1)
|
||||
|
||||
@@ -1296,7 +1295,7 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
def _insertCompletion(self, pos: int, length: int, text: str) -> None:
|
||||
"""Insert choice from the completer menu."""
|
||||
cursor = self.textCursor()
|
||||
if (block := cursor.block()).isValid():
|
||||
if (block := cursor.block()).isValid(): # pragma: no branch
|
||||
check = pos + block.position()
|
||||
cursor.setPosition(check, QtMoveAnchor)
|
||||
cursor.setPosition(check + length, QtKeepAnchor)
|
||||
@@ -1391,7 +1390,7 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
action.triggered.connect(qtLambda(self._addWord, word, block, True))
|
||||
|
||||
# Execute the context menu
|
||||
if viewport := self.viewport():
|
||||
if viewport := self.viewport(): # pragma: no branch
|
||||
ctxMenu.exec(viewport.mapToGlobal(pos))
|
||||
|
||||
ctxMenu.setParent(None)
|
||||
@@ -1810,6 +1809,8 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
cursor.setPosition(posE + len(before), QtKeepAnchor)
|
||||
elif select == _SelectAction.KEEP_POSITION:
|
||||
cursor.setPosition(posO + len(before))
|
||||
else: # pragma: no cover
|
||||
pass
|
||||
|
||||
self.setTextCursor(cursor)
|
||||
|
||||
@@ -2531,7 +2532,10 @@ class GuiDocEditor(QPlainTextEdit):
|
||||
|
||||
# Scan forward
|
||||
ePos = cPos
|
||||
for i in range(bPos + bLen - cPos):
|
||||
for i in range(bPos + bLen - cPos): # pragma: no branch
|
||||
# The block's trailing (non-alnum) separator character is
|
||||
# always within range, so this loop always finds a
|
||||
# boundary and breaks before running to exhaustion.
|
||||
ePos = cPos + i
|
||||
cOne = str(self._qDocument.characterAt(ePos))
|
||||
cTwo = str(self._qDocument.characterAt(ePos + 1))
|
||||
@@ -3775,7 +3779,7 @@ class GuiDocEditFooter(QWidget):
|
||||
|
||||
def updateLineCount(self, cursor: QTextCursor) -> None:
|
||||
"""Update the line and document position counter."""
|
||||
if document := cursor.document():
|
||||
if document := cursor.document(): # pragma: no branch
|
||||
cPos = cursor.position() + 1
|
||||
cLine = cursor.blockNumber() + 1
|
||||
cCount = max(document.characterCount(), 1)
|
||||
|
||||
@@ -754,7 +754,7 @@ class GuiProjectTree(QTreeView):
|
||||
"""Duplicate the item hierarchy from a given item."""
|
||||
itemTree = [tHandle]
|
||||
itemTree.extend(SHARED.project.tree.subTree(tHandle))
|
||||
if itemTree:
|
||||
if itemTree: # pragma: no branch
|
||||
if len(itemTree) == 1:
|
||||
question = self.tr("Do you want to duplicate this document?")
|
||||
else:
|
||||
@@ -923,7 +923,7 @@ class GuiProjectTree(QTreeView):
|
||||
if model := self._getModel():
|
||||
if point is None:
|
||||
point = self.visualRect(self.currentIndex()).center()
|
||||
if point is not None:
|
||||
if point is not None: # pragma: no branch
|
||||
index = self.indexAt(point)
|
||||
if (node := self._getNode(index)) and (indices := self._selectedRows()):
|
||||
ctxMenu = _TreeContextMenu(self, model, node, indices)
|
||||
@@ -933,7 +933,7 @@ class GuiProjectTree(QTreeView):
|
||||
ctxMenu.buildMultiSelectMenu()
|
||||
else:
|
||||
ctxMenu.buildSingleSelectMenu()
|
||||
if viewport := self.viewport():
|
||||
if viewport := self.viewport(): # pragma: no branch
|
||||
ctxMenu.exec(viewport.mapToGlobal(point))
|
||||
ctxMenu.setParent(None)
|
||||
|
||||
@@ -1048,7 +1048,7 @@ class _UpdatableMenu(QMenu):
|
||||
|
||||
def setActionsVisible(self, value: bool) -> None:
|
||||
"""Set the visibility of root action."""
|
||||
if action := self.menuAction():
|
||||
if action := self.menuAction(): # pragma: no branch
|
||||
action.setVisible(value)
|
||||
|
||||
##
|
||||
|
||||
@@ -10,7 +10,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _genCoverageConfig(path: Path, branch: bool) -> None:
|
||||
def _genCoverageConfig(path: Path) -> None:
|
||||
"""Generate a config file for coverage.py."""
|
||||
exclude = [] # ["if TYPE_CHECKING:"]
|
||||
if sys.platform != "linux":
|
||||
@@ -25,7 +25,7 @@ def _genCoverageConfig(path: Path, branch: bool) -> None:
|
||||
|
||||
config = [
|
||||
"[run]",
|
||||
f"branch = {str(branch).lower()}",
|
||||
"branch = true",
|
||||
"source = novelwriter",
|
||||
"",
|
||||
"[report]",
|
||||
@@ -45,7 +45,6 @@ if __name__ == "__main__":
|
||||
parser.add_argument("-t", action="store_true", help="Generate terminal report")
|
||||
parser.add_argument("-u", action="store_true", help="Generate uncovered terminal report")
|
||||
parser.add_argument("-x", action="store_true", help="Ignore OS-specific tests in reports")
|
||||
parser.add_argument("-b", action="store_true", help="Check branch coverage")
|
||||
parser.add_argument("-lf", action="store_true", help="Re-run failed tests")
|
||||
parser.add_argument("-sw", action="store_true", help="Run tests stepwise")
|
||||
parser.add_argument("-m", help="Test modules", metavar="MARKEXPR")
|
||||
@@ -59,7 +58,7 @@ if __name__ == "__main__":
|
||||
|
||||
if args.r or args.t or args.u:
|
||||
if args.x:
|
||||
_genCoverageConfig(tmpConf, args.b)
|
||||
_genCoverageConfig(tmpConf)
|
||||
cmd = ["coverage", "run"]
|
||||
if args.lf or args.sw:
|
||||
cmd += ["--append"]
|
||||
|
||||
@@ -21,6 +21,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from time import time
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -48,9 +49,10 @@ from novelwriter.common import decodeMimeHandles
|
||||
from novelwriter.constants import nwKeyWords, nwUnicode
|
||||
from novelwriter.core.item import NWItem
|
||||
from novelwriter.dialogs.editlabel import GuiEditLabel
|
||||
from novelwriter.enum import nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwState
|
||||
from novelwriter.enum import nwComment, nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwState, nwVimMode
|
||||
from novelwriter.gui.doceditor import CommandCompleter, GuiDocEditor, TextAutoReplace, _TagAction
|
||||
from novelwriter.gui.dochighlight import TextBlockData
|
||||
from novelwriter.shared import _GuiAlert
|
||||
from novelwriter.text.counting import standardCounter
|
||||
from novelwriter.types import (
|
||||
QtAlignJustify,
|
||||
@@ -158,6 +160,17 @@ def testGuiEditor_Init(qtbot, nwGUI, projPath, ipsumText, mockRnd):
|
||||
docEditor.docHeader._processLabelLink(f"#{docEditor.docHeader._docHandle}")
|
||||
assert signal.args == [docEditor.docHeader._docHandle, True]
|
||||
|
||||
# A link that isn't a project item reference is ignored
|
||||
with qtbot.assertNotEmitted(docEditor.requestProjectItemSelected, wait=100):
|
||||
docEditor.docHeader._processLabelLink("https://example.com")
|
||||
|
||||
# An invalid handle, with the full path setting off, leaves the
|
||||
# title unchanged
|
||||
assert CONFIG.showFullPath is False
|
||||
titleBefore = docEditor.docHeader.itemTitle.text()
|
||||
docEditor.docHeader.setHandle(C.hInvalid)
|
||||
assert docEditor.docHeader.itemTitle.text() == titleBefore
|
||||
|
||||
# Close from header
|
||||
with qtbot.waitSignal(docEditor.docHeader.closeDocumentRequest, timeout=1000):
|
||||
docEditor.docHeader.closeButton.click()
|
||||
@@ -242,6 +255,38 @@ def testGuiEditor_SaveText(qtbot, monkeypatch, caplog, nwGUI, projPath, ipsumTex
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_SaveTextEdgeCases(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd):
|
||||
"""Test defensive branches in saveText not covered by the main save
|
||||
text test: a write failure with no hash mismatch, and a successful
|
||||
forced overwrite after a hash mismatch.
|
||||
"""
|
||||
buildTestProject(nwGUI, projPath)
|
||||
assert nwGUI.openDocument(C.hSceneDoc) is True
|
||||
docEditor = nwGUI.docEditor
|
||||
|
||||
text = "### Lorem Ipsum\n\n{0}".format("\n\n".join(ipsumText))
|
||||
docEditor.replaceText(text)
|
||||
|
||||
nwDocument = docEditor._nwDocument
|
||||
assert nwDocument is not None
|
||||
|
||||
# A write failure that isn't due to a hash mismatch does not prompt
|
||||
# the user to overwrite, and simply reports the error
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(type(nwDocument), "writeDocument", lambda *a, **k: False)
|
||||
mp.setattr(type(nwDocument), "hashError", property(lambda self: False))
|
||||
assert docEditor.saveText() is False
|
||||
assert SHARED.lastAlert[0] == "Could not save document."
|
||||
|
||||
# A hash mismatch that the user confirms overwriting for, and which
|
||||
# then succeeds, is treated as a successful save
|
||||
nwDocument._lastHash = "0" * 40
|
||||
docEditor.setDocumentChanged(True)
|
||||
assert docEditor.saveText() is True
|
||||
assert docEditor.docChanged is False
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_DragAndDrop(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test drag and drop in the editor."""
|
||||
@@ -310,6 +355,12 @@ def testGuiEditor_DragAndDrop(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
docEditor.dropEvent(noneEvent)
|
||||
assert mockDrop.call_count == 1
|
||||
|
||||
# A dropped handle that isn't a file (e.g. a folder) does nothing
|
||||
folderMime = model.mimeData([model.indexFromHandle(C.hChapterDir)])
|
||||
folderEvent = QDropEvent(middle, action, folderMime, mouse, QtModNone)
|
||||
docEditor.dropEvent(folderEvent)
|
||||
assert docEditor.docHandle == C.hSceneDoc
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd):
|
||||
@@ -343,6 +394,30 @@ def testGuiEditor_MetaData(qtbot, nwGUI, projPath, mockRnd):
|
||||
docEditor.setCursorLine(3)
|
||||
assert docEditor.getCursorPosition() == 15
|
||||
|
||||
# An out-of-range line number does not move the cursor
|
||||
docEditor.setCursorLine(1000)
|
||||
assert docEditor.getCursorPosition() == 15
|
||||
|
||||
# Cursor Selection
|
||||
docEditor.setCursorSelection(0, 3)
|
||||
assert docEditor.textCursor().selectedText() == "###"
|
||||
|
||||
# Invalid selections are ignored
|
||||
docEditor.setCursorSelection(-1, 3)
|
||||
assert docEditor.textCursor().selectedText() == "###"
|
||||
docEditor.setCursorSelection(0, 0)
|
||||
assert docEditor.textCursor().selectedText() == "###"
|
||||
|
||||
# Reveal Location
|
||||
docEditor.revealLocation()
|
||||
assert SHARED.lastAlert[0] == "Document Details"
|
||||
|
||||
nwDocument = docEditor._nwDocument
|
||||
docEditor._nwDocument = None
|
||||
docEditor.revealLocation()
|
||||
assert SHARED.lastAlert[0] == "Document Details" # Unchanged, nothing happened
|
||||
docEditor._nwDocument = nwDocument
|
||||
|
||||
# Document Changed Signal
|
||||
docEditor._docChanged = False
|
||||
with qtbot.waitSignal(docEditor.editedStatusChanged, raising=True, timeout=100):
|
||||
@@ -644,6 +719,18 @@ def testGuiEditor_SpellChecking(qtbot, monkeypatch, nwGUI, projPath, ipsumText,
|
||||
ctxMenu.setObjectName("")
|
||||
ctxMenu.deleteLater()
|
||||
|
||||
# No spelling error at the clicked position: no spelling actions
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(SHARED.spelling, "suggestWords", lambda *a: [])
|
||||
|
||||
ctxMenu = getMenuForPos(docEditor, blockPos + 20)
|
||||
assert ctxMenu is not None
|
||||
actions = [x.text() for x in ctxMenu.actions()]
|
||||
assert "Ignore Word" not in actions
|
||||
assert "Spelling Suggestion(s)" not in actions
|
||||
ctxMenu.setObjectName("")
|
||||
ctxMenu.deleteLater()
|
||||
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@@ -1198,6 +1285,18 @@ def testGuiEditor_Insert(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd
|
||||
assert "%Footnote." in docEditor.getText()
|
||||
assert docEditor.getCursorPosition() > count
|
||||
|
||||
# No document open: nothing is inserted
|
||||
textBefore = docEditor.getText()
|
||||
docHandle = docEditor._docHandle
|
||||
docEditor._docHandle = None
|
||||
docEditor._insertCommentStructure(nwComment.FOOTNOTE)
|
||||
assert docEditor.getText() == textBefore
|
||||
docEditor._docHandle = docHandle
|
||||
|
||||
# Not a footnote comment: nothing is inserted
|
||||
docEditor._insertCommentStructure(nwComment.SYNOPSIS)
|
||||
assert docEditor.getText() == textBefore
|
||||
|
||||
# Insert KeyWords
|
||||
# ===============
|
||||
|
||||
@@ -1418,6 +1517,20 @@ def testGuiEditor_TextManipulation(qtbot, nwGUI, projPath, ipsumText, mockRnd):
|
||||
assert newPara[6] == twoBits[4]
|
||||
assert newPara[7] == " ".join(twoBits[5:])
|
||||
|
||||
# Remove on an Empty Document
|
||||
# No blocks are selected, so there is nothing to clean up
|
||||
docEditor.replaceText("")
|
||||
docEditor._removeInParLineBreaks()
|
||||
assert docEditor.getText() == "\n"
|
||||
|
||||
# Remove Ending on a Heading
|
||||
# The trailing heading isn't accumulated as paragraph text
|
||||
text = f"### A Scene\n\n{parOne}\n\n### Another Heading"
|
||||
docEditor.replaceText(text)
|
||||
docEditor.docAction(nwDocAction.SEL_ALL)
|
||||
docEditor._removeInParLineBreaks()
|
||||
assert docEditor.getText() == f"### A Scene\n\n{ipsumText[0]}\n\n### Another Heading\n"
|
||||
|
||||
# Key Press Events
|
||||
# ================
|
||||
text = f"### A Scene\n\n{parOne}\n\n{parTwo}"
|
||||
@@ -1977,6 +2090,11 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
|
||||
assert nwGUI.closeViewerPanel() is True
|
||||
assert docViewer._docHandle is None
|
||||
|
||||
# A mouse release without the Ctrl modifier does not follow the tag
|
||||
event = QMouseEvent(QEvent.Type.MouseButtonPress, position, QtMouseLeft, QtMouseLeft, QtModNone)
|
||||
docEditor.mouseReleaseEvent(event)
|
||||
assert docViewer._docHandle is None
|
||||
|
||||
# On Known Tag, Follow and Edit
|
||||
assert docEditor._processTag(follow=True, edit=True) == _TagAction.FOLLOW
|
||||
assert docEditor._docHandle == cHandle
|
||||
@@ -2005,6 +2123,59 @@ def testGuiEditor_Tags(qtbot, nwGUI, projPath, ipsumText, mockRnd):
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_ProcessTagEdgeCases(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test defensive branches in tag processing not covered by the
|
||||
main tags test: an exhausted tag search, a cursor past the last
|
||||
tag's end, a missing document handle, and a declined note-creation
|
||||
prompt.
|
||||
"""
|
||||
buildTestProject(nwGUI, projPath)
|
||||
assert nwGUI.openDocument(C.hSceneDoc) is True
|
||||
docEditor = nwGUI.docEditor
|
||||
docEditor.replaceText("### A Scene\n\n@char: Jane\n\n")
|
||||
|
||||
docEditor.setCursorLine(3)
|
||||
block = docEditor.textCursor().block()
|
||||
|
||||
index = SHARED.project.index
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(type(index), "scanThis", lambda self, text: (True, ["@char", "Jane"], [2, 5]))
|
||||
mp.setattr(type(index), "checkThese", lambda self, tBits, tHandle: [True, True])
|
||||
|
||||
# Cursor before any tag's start position: the search is
|
||||
# exhausted without ever finding a candidate tag.
|
||||
cursor = QTextCursor(block)
|
||||
cursor.setPosition(block.position())
|
||||
assert docEditor._processTag(cursor=cursor) == _TagAction.NONE
|
||||
|
||||
# Cursor past the end of the last candidate tag: the search
|
||||
# breaks on the first candidate, but it doesn't cover the cursor.
|
||||
cursor = QTextCursor(block)
|
||||
cursor.setPosition(block.position() + 10)
|
||||
assert docEditor._processTag(cursor=cursor) == _TagAction.NONE
|
||||
|
||||
# Missing document handle: nothing can be looked up
|
||||
docHandle = docEditor._docHandle
|
||||
docEditor._docHandle = None
|
||||
assert docEditor._processTag() == _TagAction.NONE
|
||||
docEditor._docHandle = docHandle
|
||||
|
||||
# No document handle for the rename-emit helper either
|
||||
docEditor._docHandle = None
|
||||
docEditor._emitRenameItem(block)
|
||||
docEditor._docHandle = docHandle
|
||||
|
||||
# User declines to create a new note for a genuinely unknown tag
|
||||
docEditor.setCursorPosition(docEditor.getText().find("Jane") + 1)
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(_GuiAlert, "finalState", False)
|
||||
countBefore = len(SHARED.project.tree)
|
||||
result = docEditor._processTag(create=True)
|
||||
assert result & _TagAction.CREATE
|
||||
assert len(SHARED.project.tree) == countBefore
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_MoveTextToNewDocument(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd):
|
||||
"""Test the moving text to new document feature."""
|
||||
@@ -2049,6 +2220,30 @@ def testGuiEditor_MoveTextToNewDocument(qtbot, monkeypatch, nwGUI, projPath, ips
|
||||
assert nwGUI.openDocument(nHandle) is True
|
||||
assert docEditor.getText() == f"### Another Scene\n\n{ipsumText[3]}\n"
|
||||
|
||||
# An empty document has no text to select, so nothing happens
|
||||
emptyHandle = SHARED.project.newFile("Empty", C.hNovelRoot)
|
||||
assert nwGUI.openDocument(emptyHandle) is True
|
||||
countBefore = len(SHARED.project.tree)
|
||||
docEditor.docAction(nwDocAction.MOVE_TEXT)
|
||||
assert len(SHARED.project.tree) == countBefore
|
||||
|
||||
# Cancelling the label dialog does not create a new document
|
||||
assert nwGUI.openDocument(C.hSceneDoc) is True
|
||||
docEditor.docAction(nwDocAction.SEL_ALL)
|
||||
countBefore = len(SHARED.project.tree)
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(GuiEditLabel, "getLabel", lambda *a, text, info: (text, False))
|
||||
docEditor.docAction(nwDocAction.MOVE_TEXT)
|
||||
assert len(SHARED.project.tree) == countBefore
|
||||
|
||||
# A failure to write the new document leaves no new item behind
|
||||
docEditor.docAction(nwDocAction.SEL_ALL)
|
||||
countBefore = len(SHARED.project.tree)
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(type(SHARED.project), "writeNewFile", lambda *a, **k: False)
|
||||
docEditor.docAction(nwDocAction.MOVE_TEXT)
|
||||
assert len(SHARED.project.tree) == countBefore + 1 # The new (empty) file is still created
|
||||
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@@ -2074,6 +2269,76 @@ def testGuiEditor_Links(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd)
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_InternalSlotEdgeCases(qtbot, nwGUI, projPath, mockRnd):
|
||||
"""Test defensive branches in a few internal slots and functions
|
||||
that aren't covered by their respective feature tests.
|
||||
"""
|
||||
buildTestProject(nwGUI, projPath)
|
||||
assert nwGUI.openDocument(C.hSceneDoc) is True
|
||||
docEditor = nwGUI.docEditor
|
||||
docEditor.replaceText("### A Scene\n\nSome text.\n\n")
|
||||
|
||||
# A content change at an invalid document position is ignored
|
||||
docEditor._docChange(-1, 0, 1)
|
||||
|
||||
# With line highlighting off, the cursor moved slot doesn't set up
|
||||
# an extra selection
|
||||
lineHighlight = CONFIG.lineHighlight
|
||||
try:
|
||||
CONFIG.lineHighlight = False
|
||||
docEditor._cursorMoved()
|
||||
finally:
|
||||
CONFIG.lineHighlight = lineHighlight
|
||||
|
||||
# Mime data insertion with no source, or a source with no text, is
|
||||
# a no-op
|
||||
text = docEditor.getText()
|
||||
docEditor.insertFromMimeData(None)
|
||||
assert docEditor.getText() == text
|
||||
|
||||
emptyMime = QMimeData()
|
||||
docEditor.insertFromMimeData(emptyMime)
|
||||
assert docEditor.getText() == text
|
||||
|
||||
# The completer only repositions itself while visible
|
||||
docEditor._completer.hide()
|
||||
assert docEditor._completer.isVisible() is False
|
||||
docEditor._completerToCursor()
|
||||
|
||||
# A completer trigger action without completer data does nothing
|
||||
action = QAction()
|
||||
with qtbot.assertNotEmitted(docEditor._completer.insertText, wait=100):
|
||||
docEditor._completer._emitComplete(action)
|
||||
|
||||
# With auto-select disabled, or with an existing selection, the
|
||||
# cursor is returned unchanged
|
||||
autoSelect = CONFIG.autoSelect
|
||||
try:
|
||||
docEditor.setPlainText("hello world")
|
||||
docEditor.setCursorPosition(2)
|
||||
|
||||
CONFIG.autoSelect = False
|
||||
assert docEditor._autoSelect().hasSelection() is False
|
||||
|
||||
CONFIG.autoSelect = True
|
||||
docEditor.docAction(nwDocAction.SEL_ALL)
|
||||
assert docEditor._autoSelect().selectedText() == "hello world"
|
||||
finally:
|
||||
CONFIG.autoSelect = autoSelect
|
||||
|
||||
# A word that is the entire (and only) block, with the cursor at
|
||||
# its start, exhausts both the backward and forward scans
|
||||
docEditor.setPlainText("hello")
|
||||
docEditor.setCursorPosition(0)
|
||||
CONFIG.autoSelect = True
|
||||
try:
|
||||
cursor = docEditor._autoSelect()
|
||||
assert cursor.selectedText() != ""
|
||||
finally:
|
||||
CONFIG.autoSelect = autoSelect
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd):
|
||||
"""Test the document editor meta completer functionality."""
|
||||
@@ -2223,6 +2488,26 @@ def testGuiEditor_Completer(qtbot, nwGUI, projPath, mockRnd):
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_UpdateDocMargins(qtbot, nwGUI, projPath, mockRnd):
|
||||
"""Test that the margins collapse to the viewport padding when no
|
||||
fixed text width or Focus Mode is in effect.
|
||||
"""
|
||||
buildTestProject(nwGUI, projPath)
|
||||
nwGUI.openDocument(C.hSceneDoc)
|
||||
docEditor = nwGUI.docEditor
|
||||
|
||||
textWidth = CONFIG.textWidth
|
||||
focusMode = SHARED.focusMode
|
||||
try:
|
||||
CONFIG.textWidth = 0
|
||||
SHARED.setFocusMode(False)
|
||||
docEditor.updateDocMargins()
|
||||
finally:
|
||||
CONFIG.textWidth = textWidth
|
||||
SHARED.setFocusMode(focusMode)
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_CursorVisibility(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test the custom ensure cursor visible feature."""
|
||||
@@ -2257,6 +2542,48 @@ def testGuiEditor_CursorVisibility(qtbot, monkeypatch, nwGUI, projPath, mockRnd)
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_ReplaceNextEdgeCases(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test defensive branches in replaceNext not covered by the main
|
||||
search test: replacing with match-case disabled, and a selection
|
||||
that doesn't match the last recorded find.
|
||||
"""
|
||||
monkeypatch.setattr(GuiDocEditor, "anyFocus", lambda *a: True)
|
||||
|
||||
buildTestProject(nwGUI, projPath)
|
||||
assert nwGUI.openDocument(C.hSceneDoc) is True
|
||||
docEditor = nwGUI.docEditor
|
||||
docSearch = docEditor.docSearch
|
||||
|
||||
docEditor.replaceText("### Scene\n\nFoo Bar Foo\n\n")
|
||||
docEditor.beginSearch()
|
||||
docSearch.setSearchText("Foo")
|
||||
docSearch.setReplaceText("Baz")
|
||||
|
||||
searchMatchCap = CONFIG.searchMatchCap
|
||||
try:
|
||||
# With match-case disabled, the replacement text is used verbatim
|
||||
CONFIG.searchMatchCap = False
|
||||
docEditor.setCursorPosition(0)
|
||||
docEditor._lastFind = None
|
||||
docEditor.replaceNext() # First call just finds and selects "Foo"
|
||||
docEditor.replaceNext() # Second call performs the replacement
|
||||
assert docEditor.getText().count("Baz") == 1
|
||||
finally:
|
||||
CONFIG.searchMatchCap = searchMatchCap
|
||||
|
||||
# A selection that doesn't match the last recorded find is treated
|
||||
# as a user selection, and is left untouched
|
||||
docEditor.setCursorPosition(0)
|
||||
docEditor._lastFind = None
|
||||
docEditor.replaceNext() # Selects and records the next "Foo"
|
||||
assert docEditor.textCursor().hasSelection() is True
|
||||
textBefore = docEditor.getText()
|
||||
docEditor._lastFind = (0, 0) # Deliberately mismatched
|
||||
docEditor.replaceNext()
|
||||
assert docEditor.getText() == textBefore
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, mockRnd):
|
||||
"""Test the word counter."""
|
||||
@@ -2320,6 +2647,15 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m
|
||||
assert SHARED.project.tree[C.hSceneDoc]._paraCount == pC # type: ignore
|
||||
assert docEditor.docFooter.wordsText.text() == f"Words: {wC} (+{wC})"
|
||||
|
||||
# While a selection is active, the main word count footer is left
|
||||
# alone since the selection counter takes precedence
|
||||
docEditor.docAction(nwDocAction.SEL_ALL)
|
||||
assert docEditor.textCursor().hasSelection() is True
|
||||
footerBefore = docEditor.docFooter.wordsText.text()
|
||||
docEditor._updateDocCounts(cC, wC + 1, pC)
|
||||
assert docEditor.docFooter.wordsText.text() == footerBefore
|
||||
docEditor.setCursorPosition(0)
|
||||
|
||||
# Select all text and run the selection word counter
|
||||
docEditor.docAction(nwDocAction.SEL_ALL)
|
||||
docEditor._runSelCounter()
|
||||
@@ -2328,6 +2664,12 @@ def testGuiEditor_WordCounters(qtbot, monkeypatch, nwGUI, projPath, ipsumText, m
|
||||
docEditor._wCounterSel.run()
|
||||
assert docEditor.docFooter.wordsText.text() == f"Selected: {wC}"
|
||||
|
||||
# If the last edit is old, no document tasks are run
|
||||
threadPool._objID = None
|
||||
docEditor._lastEdit = time() - 100.0
|
||||
docEditor._runDocumentTasks()
|
||||
assert threadPool.objectID() is None
|
||||
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@@ -2798,6 +3140,24 @@ def testGuiEditor_Vim_EnableVimMode(qtbot, nwGUI, projPath, mockRnd):
|
||||
assert "TEST" in new_text
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_Vim_StateInsertModeNoOp(qtbot, nwGUI, projPath, mockRnd):
|
||||
"""Test that the vim state machine ignores command keys while in
|
||||
INSERT mode, which is a state neither the NORMAL nor VISUAL mode
|
||||
key handlers ever pass through.
|
||||
"""
|
||||
buildTestProject(nwGUI, projPath)
|
||||
assert nwGUI.openDocument(C.hSceneDoc)
|
||||
docEditor = nwGUI.docEditor
|
||||
|
||||
vim = docEditor._vim
|
||||
vim.setMode(nwVimMode.INSERT)
|
||||
vim.pushCommandKey("d")
|
||||
assert vim.command == ""
|
||||
vim.setCommand("d")
|
||||
assert vim.command == ""
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiEditor_Vim_InsertMode(qtbot, nwGUI, projPath, mockRnd):
|
||||
"""Test vim hjkl movements and insert commands (i, I, A)."""
|
||||
@@ -2907,6 +3267,15 @@ def testGuiEditor_Vim_DeleteYankPaste(qtbot, nwGUI, projPath, mockRnd):
|
||||
docEditor.setPlainText("Line1\nLine2\nLine3")
|
||||
return docEditor.getText()
|
||||
|
||||
# p/P: Pasting with nothing yanked yet does nothing
|
||||
resetText()
|
||||
assert docEditor._vim.pasteFromInternal() == ""
|
||||
docEditor.setCursorPosition(0)
|
||||
qtbot.keyClicks(docEditor, "p", delay=inputDelay)
|
||||
assert docEditor.getText() == "Line1\nLine2\nLine3"
|
||||
qtbot.keyClicks(docEditor, "P", delay=inputDelay)
|
||||
assert docEditor.getText() == "Line1\nLine2\nLine3"
|
||||
|
||||
# dd: Delete entire line
|
||||
resetText()
|
||||
docEditor.setCursorPosition(docEditor.getText().find("Line2"))
|
||||
@@ -2973,6 +3342,13 @@ def testGuiEditor_Vim_DeleteYankPaste(qtbot, nwGUI, projPath, mockRnd):
|
||||
lines = list(filter(str.strip, docEditor.getText().splitlines()))
|
||||
assert lines == ["Line1", "Line2"]
|
||||
|
||||
# de: Already at the very end of the document, nowhere further to delete
|
||||
docEditor.setPlainText("Line1")
|
||||
end_pos = len(docEditor.getText())
|
||||
docEditor.setCursorPosition(end_pos)
|
||||
qtbot.keyClicks(docEditor, "de", delay=inputDelay)
|
||||
assert docEditor.getText() == "Line1"
|
||||
|
||||
# db: Delete word back (from Line2 up to end of word boundary)
|
||||
resetText()
|
||||
docEditor.setCursorPosition(0) # Start of Line1
|
||||
@@ -3103,6 +3479,14 @@ def testGuiEditor_Vim_VisualMode(qtbot, nwGUI, projPath, mockRnd):
|
||||
# Should have selected whitespace + "lineExtra"
|
||||
assert "lineExtra" in selected
|
||||
|
||||
# At the very end of the document, there's nowhere further to extend to
|
||||
docEditor.setPlainText("Line1")
|
||||
end_pos = len(docEditor.getText())
|
||||
docEditor.setCursorPosition(end_pos)
|
||||
qtbot.keyClick(docEditor, "v", delay=inputDelay) # Enter visual mode
|
||||
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
|
||||
assert docEditor.textCursor().position() == end_pos
|
||||
|
||||
resetText()
|
||||
# Visual select all with ggVG
|
||||
qtbot.keyClicks(docEditor, "g", delay=inputDelay)
|
||||
@@ -3228,6 +3612,13 @@ def testGuiEditor_Vim_NormalMode(qtbot, nwGUI, projPath, mockRnd):
|
||||
expectedPos = docEditor.getText().find("lineExtra") + len("lineExtra")
|
||||
assert cursorPos == expectedPos
|
||||
|
||||
# e: At the very end of the document, there's nowhere further to move
|
||||
docEditor.setPlainText("Line1")
|
||||
end_pos = len(docEditor.getText())
|
||||
docEditor.setCursorPosition(end_pos)
|
||||
qtbot.keyClicks(docEditor, "e", delay=inputDelay)
|
||||
assert docEditor.textCursor().position() == end_pos
|
||||
|
||||
# $: Move to end of first line
|
||||
docEditor.setCursorPosition(0) # Start of Line1
|
||||
qtbot.keyClick(docEditor, "$", delay=inputDelay)
|
||||
|
||||
@@ -30,6 +30,7 @@ from PyQt6.QtGui import QMouseEvent
|
||||
from PyQt6.QtWidgets import QMenu
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.core.coretools import DocMerger
|
||||
from novelwriter.dialogs.docmerge import GuiDocMerge
|
||||
from novelwriter.dialogs.docsplit import GuiDocSplit
|
||||
from novelwriter.dialogs.editlabel import GuiEditLabel
|
||||
@@ -50,6 +51,32 @@ from tests.mocked import causeOSError
|
||||
from tests.tools import C, buildTestProject
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_NoProjectEdgeCases(qtbot, nwGUI):
|
||||
"""Test project tree and view methods called when no project is
|
||||
open, and thus no model has been assigned to the tree view.
|
||||
"""
|
||||
projView = nwGUI.projView
|
||||
projTree = projView.projTree
|
||||
|
||||
assert projTree._getModel() is None
|
||||
|
||||
# None of these should raise, and should just do nothing
|
||||
projTree.restoreExpandedState()
|
||||
projTree.setSelectedHandle(C.hNovelRoot)
|
||||
projTree.moveItemUp()
|
||||
projTree.moveItemDown()
|
||||
projTree._clearSelection()
|
||||
projTree.collapseFromIndex(QModelIndex())
|
||||
projTree.openContextMenu()
|
||||
projTree._onDoubleClick(QModelIndex())
|
||||
projTree._onNodeCollapsed(QModelIndex())
|
||||
projTree._onNodeExpanded(QModelIndex())
|
||||
|
||||
# Renaming with no selection and no valid handle does nothing
|
||||
projView.renameTreeItem()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test adding items to the project tree."""
|
||||
@@ -505,6 +532,53 @@ def testGuiProjTree_NewTreeItem(qtbot, caplog, monkeypatch, nwGUI, projPath, moc
|
||||
nwGUI.closeProject()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_RenameCancelled(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test that cancelling the rename dialog leaves the item name
|
||||
unchanged.
|
||||
"""
|
||||
buildTestProject(nwGUI, projPath)
|
||||
project = SHARED.project
|
||||
projView = nwGUI.projView
|
||||
|
||||
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("New Name", False))
|
||||
projView.renameTreeItem(C.hNovelRoot)
|
||||
item = project.tree[C.hNovelRoot]
|
||||
assert item is not None
|
||||
assert item.itemName == "Novel"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_NewTreeItemFailures(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test the defensive checks in newTreeItem for when the tree
|
||||
cannot find a valid parent, or the file creation itself fails.
|
||||
"""
|
||||
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, **k: ("New Item", True))
|
||||
|
||||
projView = nwGUI.projView
|
||||
projTree = projView.projTree
|
||||
project = SHARED.project
|
||||
tree = project.tree
|
||||
|
||||
buildTestProject(nwGUI, projPath)
|
||||
|
||||
# No valid parent handle is found for the new item
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(type(tree), "pickParent", lambda *a, **k: (None, -1))
|
||||
projView.setSelectedHandle(C.hChapterDoc, doScroll=True)
|
||||
countBefore = len(tree)
|
||||
projTree.newTreeItem(nwItemType.FILE)
|
||||
assert len(tree) == countBefore
|
||||
|
||||
# File creation itself fails
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(type(project), "newFile", lambda *a, **k: None)
|
||||
projView.setSelectedHandle(C.hChapterDoc, doScroll=True)
|
||||
countBefore = len(tree)
|
||||
projTree.newTreeItem(nwItemType.FILE)
|
||||
assert len(tree) == countBefore
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_SimpleOperations(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test simple operations in the project tree like internal move,
|
||||
@@ -752,6 +826,44 @@ def testGuiProjTree_MouseClicks(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
assert projView.getSelectedHandle() is None
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_MouseAndNavEdgeCases(qtbot, nwGUI, projPath, mockRnd):
|
||||
"""Test mouse press and keyboard navigation edge cases not covered
|
||||
by the other tests.
|
||||
"""
|
||||
projView = nwGUI.projView
|
||||
projTree = projView.projTree
|
||||
model = SHARED.project.tree.model
|
||||
|
||||
buildTestProject(nwGUI, projPath)
|
||||
projTree.expandAll()
|
||||
|
||||
eType = QEvent.Type.MouseButtonPress
|
||||
modifier = QtModNone
|
||||
|
||||
# Left click on a valid item does not clear the selection
|
||||
projTree.setSelectedHandle(C.hChapterDoc)
|
||||
pos = QPointF(projTree.visualRect(model.indexFromHandle(C.hChapterDoc)).center())
|
||||
event = QMouseEvent(eType, pos, QtMouseLeft, QtMouseLeft, modifier)
|
||||
projTree.mousePressEvent(event)
|
||||
assert projView.getSelectedHandle() == C.hChapterDoc
|
||||
|
||||
# Middle click on a folder does not open the viewer
|
||||
assert nwGUI.docViewer.docHandle is None
|
||||
pos = QPointF(projTree.visualRect(model.indexFromHandle(C.hChapterDir)).center())
|
||||
event = QMouseEvent(eType, pos, QtMouseMiddle, QtMouseMiddle, modifier)
|
||||
projTree.mousePressEvent(event)
|
||||
assert nwGUI.docViewer.docHandle is None
|
||||
|
||||
# A leaf item has no first child to move to
|
||||
projTree.setSelectedHandle(C.hSceneDoc)
|
||||
projTree.goToFirstChild()
|
||||
assert projView.getSelectedHandle() == C.hSceneDoc
|
||||
|
||||
# Collapsing an invalid index does nothing
|
||||
projTree.collapseFromIndex(QModelIndex())
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_DeleteRequest(qtbot, caplog, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test delete requests in the project tree."""
|
||||
@@ -1038,6 +1150,43 @@ def testGuiProjTree_DeleteRequest(qtbot, caplog, monkeypatch, nwGUI, projPath, m
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_DeleteEdgeCases(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test defensive branches in the delete/trash handling that are
|
||||
not covered by the main delete request test.
|
||||
"""
|
||||
projTree = nwGUI.projView.projTree
|
||||
project = SHARED.project
|
||||
tree = project.tree
|
||||
|
||||
buildTestProject(nwGUI, projPath)
|
||||
|
||||
# Move a scene to trash so there is one valid trash handle
|
||||
projTree.processDeleteRequest([C.hSceneDoc], askFirst=False)
|
||||
trash = tree.trash
|
||||
assert trash is not None
|
||||
assert [n.item.itemHandle for n in trash.allChildren()] == [C.hSceneDoc]
|
||||
|
||||
# A bogus handle in the list resolves to an invalid index in the
|
||||
# model, and is silently skipped while the valid one is deleted
|
||||
projTree.processDeleteRequest([C.hSceneDoc, C.hInvalid], askFirst=False)
|
||||
assert trash.childCount() == 0
|
||||
|
||||
# If there is no trash root, and the selection isn't already trash,
|
||||
# the request falls through and does nothing
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(type(tree), "trash", property(lambda self: None))
|
||||
projTree.processDeleteRequest([C.hChapterDoc], askFirst=False)
|
||||
assert C.hChapterDoc in tree
|
||||
|
||||
# Emptying the trash when it doesn't exist does nothing
|
||||
countBefore = len(tree)
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(type(tree), "trash", property(lambda self: None))
|
||||
projTree.emptyTrash()
|
||||
assert len(tree) == countBefore
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
|
||||
"""Test the merge document function."""
|
||||
@@ -1205,6 +1354,36 @@ def testGuiProjTree_MergeDocuments(qtbot, monkeypatch, nwGUI, projPath, mockRnd,
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_MergeTargetHandleEdgeCase(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test the defensive check after a successful merge, in case the
|
||||
merger somehow ends up without a valid target handle.
|
||||
"""
|
||||
mergeData = {"finalItems": [], "moveToTrash": False}
|
||||
|
||||
monkeypatch.setattr(GuiDocMerge, "__init__", lambda *a: None)
|
||||
monkeypatch.setattr(GuiDocMerge, "exec", lambda *a: None)
|
||||
monkeypatch.setattr(GuiDocMerge, "softDelete", lambda *a: None)
|
||||
monkeypatch.setattr(GuiDocMerge, "result", lambda *a: QtAccepted)
|
||||
monkeypatch.setattr(GuiDocMerge, "data", lambda *a: mergeData)
|
||||
monkeypatch.setattr(DocMerger, "writeTargetDoc", lambda *a: True)
|
||||
monkeypatch.setattr(DocMerger, "targetHandle", property(lambda self: None))
|
||||
|
||||
projView = nwGUI.projView
|
||||
projTree = projView.projTree
|
||||
project = SHARED.project
|
||||
|
||||
buildTestProject(nwGUI, projPath)
|
||||
|
||||
hChapter1 = project.newFile("Chapter 1", C.hNovelRoot)
|
||||
assert hChapter1 is not None
|
||||
|
||||
mergeData["finalItems"] = [hChapter1]
|
||||
|
||||
assert projTree.mergeDocuments(hChapter1, False) is True
|
||||
assert projView.getSelectedHandle() != hChapter1
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_SplitDocument(qtbot, monkeypatch, nwGUI, projPath, mockRnd, ipsumText):
|
||||
"""Test the split document function."""
|
||||
@@ -1914,6 +2093,105 @@ def testGuiProjTree_ContextMenu(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_ContextMenuEdgeCases(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test defensive branches in the context menu builders and their
|
||||
associated actions that aren't covered by the main context menu test.
|
||||
"""
|
||||
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, **k: (k.get("text", ""), True))
|
||||
|
||||
projView = nwGUI.projView
|
||||
projTree = projView.projTree
|
||||
project = SHARED.project
|
||||
tree = project.tree
|
||||
model = tree.model
|
||||
|
||||
buildTestProject(nwGUI, projPath)
|
||||
|
||||
# Trash menu with no children in the Trash folder
|
||||
trashNode = tree.trash
|
||||
assert trashNode is not None
|
||||
assert trashNode.childCount() == 0
|
||||
indices = [model.indexFromHandle(trashNode.item.itemHandle)]
|
||||
ctxMenu = _TreeContextMenu(projTree, model, trashNode, indices)
|
||||
ctxMenu.buildTrashMenu()
|
||||
actions = [x.text() for x in ctxMenu.actions() if x.text()]
|
||||
assert actions == ["Empty Trash"]
|
||||
|
||||
# A brand new empty document has no heading to offer as a rename
|
||||
projView.setSelectedHandle(C.hChapterDir, doScroll=True)
|
||||
projTree.newTreeItem(nwItemType.FILE, hLevel=0)
|
||||
hEmptyDoc = "0000000000011"
|
||||
assert hEmptyDoc in tree
|
||||
node = tree.nodes[hEmptyDoc]
|
||||
ctxMenu = _TreeContextMenu(projTree, model, node, [model.indexFromHandle(hEmptyDoc)])
|
||||
ctxMenu.buildSingleSelectMenu()
|
||||
actions = [x.text() for x in ctxMenu.actions() if x.text()]
|
||||
assert "Rename to Heading" not in actions
|
||||
|
||||
# Toggling active on a folder does nothing
|
||||
folderNode = tree.nodes[C.hChapterDir]
|
||||
ctxMenu = _TreeContextMenu(projTree, model, folderNode, [model.indexFromHandle(C.hChapterDir)])
|
||||
assert folderNode.item.isActive is True
|
||||
ctxMenu._toggleItemActive()
|
||||
assert folderNode.item.isActive is True
|
||||
|
||||
# Iterating active status skips non-file items in the selection
|
||||
fileNode = tree.nodes[C.hChapterDoc]
|
||||
indices = [model.indexFromHandle(C.hChapterDoc), model.indexFromHandle(C.hChapterDir)]
|
||||
ctxMenu = _TreeContextMenu(projTree, model, fileNode, indices)
|
||||
fileNode.item.setActive(True)
|
||||
ctxMenu._iterItemActive(False)
|
||||
assert fileNode.item.isActive is False
|
||||
assert folderNode.item.isFolderType()
|
||||
|
||||
# Recursing active status on a folder applies only to file children,
|
||||
# since the folder itself isn't a file
|
||||
chapNode = tree.nodes[C.hChapterDoc]
|
||||
sceneNode = tree.nodes[C.hSceneDoc]
|
||||
ctxMenu = _TreeContextMenu(projTree, model, folderNode, [model.indexFromHandle(C.hChapterDir)])
|
||||
chapNode.item.setActive(True)
|
||||
sceneNode.item.setActive(True)
|
||||
ctxMenu._recurseItemActive(False)
|
||||
assert chapNode.item.isActive is False
|
||||
assert sceneNode.item.isActive is False
|
||||
|
||||
# A folder among the children is also skipped by the recursion
|
||||
projView.setSelectedHandle(C.hChapterDir, doScroll=True)
|
||||
projTree.newTreeItem(nwItemType.FOLDER)
|
||||
hSubFolder = "0000000000012"
|
||||
assert hSubFolder in tree
|
||||
ctxMenu._recurseItemActive(True)
|
||||
assert tree.nodes[hSubFolder].item.isFolderType()
|
||||
assert chapNode.item.isActive is True
|
||||
assert sceneNode.item.isActive is True
|
||||
|
||||
# Changing the layout of a character note to Document does nothing,
|
||||
# since the class doesn't allow document layout
|
||||
projView.setSelectedHandle(C.hCharRoot, doScroll=True)
|
||||
projTree.newTreeItem(nwItemType.FILE, isNote=True)
|
||||
hCharNote = "0000000000013"
|
||||
assert hCharNote in tree
|
||||
charNode = tree.nodes[hCharNote]
|
||||
ctxMenu = _TreeContextMenu(projTree, model, charNode, [model.indexFromHandle(hCharNote)])
|
||||
assert charNode.item.itemLayout == nwItemLayout.NOTE
|
||||
ctxMenu._changeItemLayout(nwItemLayout.DOCUMENT)
|
||||
assert charNode.item.itemLayout == nwItemLayout.NOTE
|
||||
|
||||
# Converting a non-folder item does nothing
|
||||
ctxMenu._convertFolderToFile(nwItemLayout.NOTE)
|
||||
assert charNode.item.isFileType()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_TemplateMenuEdgeCase(qtbot, nwGUI):
|
||||
"""Test that removing a template handle that isn't currently in the
|
||||
templates menu does nothing.
|
||||
"""
|
||||
mTemplates = nwGUI.projView.projBar.mTemplates
|
||||
mTemplates.remove("0000000000099")
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testGuiProjTree_Templates(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test the templates feature of the project tree."""
|
||||
|
||||
Reference in New Issue
Block a user