Fix branch misses on gui modules, part 3

This commit is contained in:
Veronica Berglyd Olsen
2026-07-05 12:01:09 +02:00
parent 7b078103a2
commit 1b9ac40e38
5 changed files with 690 additions and 18 deletions

View File

@@ -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)

View File

@@ -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)
##

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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."""