Files
UmbraEdit/tests/editor/test_editor.py
Veronica Berglyd Olsen 558ac0ae0c Update tests
2026-11-03 19:28:22 +01:00

4538 lines
158 KiB
Python

"""
novelWriter - Document Editor Tests
===================================
This file is a part of novelWriter
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter 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 <https://www.gnu.org/licenses/>.
""" # noqa
from __future__ import annotations
import re
from pathlib import Path
from time import time
from typing import Any
from unittest.mock import MagicMock
import pytest
from PyQt6.QtCore import QEvent, QMimeData, QPoint, QPointF, QRect, QRunnable, Qt, QThreadPool, QUrl, QVariant
from PyQt6.QtGui import (
QAction,
QClipboard,
QColor,
QDesktopServices,
QDragEnterEvent,
QDragMoveEvent,
QDropEvent,
QFont,
QMouseEvent,
QTextBlock,
QTextCharFormat,
QTextCursor,
QTextOption,
QWheelEvent,
)
from PyQt6.QtWidgets import QAbstractButton, QApplication, QMenu, QTextEdit
from pytestqt.qtbot import QtBot
from novelwriter import CONFIG, SHARED
from novelwriter.common import decodeMimeHandles, utf16CharMap
from novelwriter.constants import nwConst, nwKeyWords, nwUnicode
from novelwriter.core.item import ProjectItem
from novelwriter.core.project import NWProject
from novelwriter.core.spellcheck import SpellEnchant
from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.dialogs.editlink import GuiEditLink
from novelwriter.editor.editor import GuiDocEditor, _TagAction
from novelwriter.editor.editsearch import GuiDocEditSearch
from novelwriter.editor.textblock import TextBlockData, formatCheckText
from novelwriter.enum import nwComment, nwDocAction, nwDocInsert, nwItemClass, nwItemLayout, nwState
from novelwriter.guimain import GuiMain
from novelwriter.shared import _GuiAlert
from novelwriter.text.counting import standardCounter
from novelwriter.types import (
QtAlignJustify,
QtAlignLeft,
QtImCurrentSelection,
QtImCursorRectangle,
QtKeepAnchor,
QtKeyReturn,
QtModCtrl,
QtModNone,
QtMouseLeft,
QtMoveAnchor,
QtMoveRight,
QtScrollAlwaysOff,
QtScrollAsNeeded,
QtSelectDocument,
QtSelectWord,
)
from tests.helpers import C, MockRnd, buildTestProject, writeFile
from tests.mocked import causeOSError
KEY_DELAY = 1
def getMenuForPos(editor: GuiDocEditor, pos: int, select: bool = False) -> QMenu | None:
"""Create a context menu for a text position and return the menu
object.
"""
cursor = editor.textCursor()
cursor.setPosition(pos)
if select:
cursor.select(QtSelectWord)
editor.setTextCursor(cursor)
editor._openContextFromCursor()
for obj in editor.children():
if isinstance(obj, QMenu) and obj.objectName() == "ContextMenu":
return obj
return None
@pytest.mark.gui
def testGuiDocEditor_Init(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, ipsumText: list[str], mockRnd: MockRnd):
"""Test initialising the editor."""
# Open project
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
docEditor.setPlainText(f"### Lorem Ipsum\n\n{ipsumText[0]}")
nwGUI.docPanes.saveDocument()
# Check Defaults
qDoc = docEditor._qDocument
assert qDoc.defaultTextOption().alignment() == QtAlignLeft
assert qDoc.defaultTextOption().textDirection() == Qt.LayoutDirection.LayoutDirectionAuto
assert docEditor.verticalScrollBarPolicy() == QtScrollAsNeeded
assert docEditor.horizontalScrollBarPolicy() == QtScrollAsNeeded
assert CONFIG.fmtPadThin is False
assert docEditor.docHeader.itemTitle.text() == (
"<font style='color: #ff303030'>"
"<a href='#0000000000008' style='color: #ff303030; text-decoration: none'>Novel</a>"
" \u203a "
"<a href='#000000000000d' style='color: #ff303030; text-decoration: none'>New Folder</a>"
" \u203a "
"<a href='#000000000000f' style='color: #ff303030; text-decoration: none'>New Scene</a>"
"</font>"
)
assert docEditor.docHeader._docOutline == {0: "### New Scene"}
# Check that editor handles settings
CONFIG.textFont = QFont()
CONFIG.doJustify = True
CONFIG.hideVScroll = True
CONFIG.hideHScroll = True
CONFIG.fmtPadThin = True
CONFIG.showFullPath = False
SHARED.project.setProjectLang(None, True)
# Whitespace display is toggled live from the footer options menu
docFooter = docEditor.docFooter
assert docFooter.optsButton.isHidden() is False
assert not qDoc.defaultTextOption().flags() & QTextOption.Flag.ShowTabsAndSpaces
docFooter.aShowTabsNSpaces.setChecked(True)
assert CONFIG.editShowTabsNSpaces is True
assert qDoc.defaultTextOption().flags() & QTextOption.Flag.ShowTabsAndSpaces
assert not qDoc.defaultTextOption().flags() & QTextOption.Flag.ShowLineAndParagraphSeparators
docFooter.aShowLineEndings.setChecked(True)
assert CONFIG.editShowLineEndings is True
assert qDoc.defaultTextOption().flags() & QTextOption.Flag.ShowLineAndParagraphSeparators
assert docEditor.isReadOnly() is False
docEditor.initEditor()
qDoc = docEditor._qDocument
assert CONFIG.textFont == qDoc.defaultFont()
assert qDoc.defaultTextOption().alignment() == QtAlignJustify
assert qDoc.defaultTextOption().textDirection() == Qt.LayoutDirection.LayoutDirectionAuto
assert qDoc.defaultTextOption().flags() & QTextOption.Flag.ShowTabsAndSpaces
assert qDoc.defaultTextOption().flags() & QTextOption.Flag.ShowLineAndParagraphSeparators
assert docEditor.verticalScrollBarPolicy() == QtScrollAlwaysOff
assert docEditor.horizontalScrollBarPolicy() == QtScrollAlwaysOff
assert CONFIG.fmtPadThin is True
assert docEditor.docHeader.itemTitle.text() == (
"<font style='color: #ff303030'>"
"<a href='#000000000000f' style='color: #ff303030; text-decoration: none'>New Scene</a>"
"</font>"
)
# Header
# ======
# Go to outline
docEditor.setCursorLine(3)
docEditor.docHeader.outlineMenu.actions()[0].trigger()
assert docEditor.getCursorPosition() == 0
# Non-integer data is ignored
otherAction = QAction(docEditor.docHeader)
otherAction.setData("not-an-int")
docEditor.docHeader._gotoBlock(otherAction)
assert docEditor.getCursorPosition() == 0
# Select item from header
with qtbot.waitSignal(docEditor.requestProjectItemSelected, timeout=1000) as signal:
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
# Toggle tool bar from header
with qtbot.waitSignal(docEditor.docHeader.toggleToolBarRequest, timeout=1000):
docEditor.docHeader.tbButton.click()
# Toggle search from header
assert docEditor.searchVisible() is False
docEditor.docHeader.searchButton.click()
assert docEditor.searchVisible() is True
docEditor.docHeader.searchButton.click()
assert docEditor.searchVisible() is False
# Toggle focus mode from header
with qtbot.waitSignal(docEditor.toggleFocusModeRequest, timeout=1000):
docEditor.docHeader.minmaxButton.click()
# Close from header
with qtbot.waitSignal(docEditor.docHeader.closeDocumentRequest, timeout=1000):
docEditor.docHeader.closeButton.click()
assert docEditor.docHeader.tbButton.isVisible() is False
assert docEditor.docHeader.searchButton.isVisible() is False
assert docEditor.docHeader.closeButton.isVisible() is False
assert docEditor.docHeader.minmaxButton.isVisible() is False
assert docEditor.docHeader.itemTitle.text() == ""
# With no document open, a toggle leaves the editor read-only
assert docEditor.docHandle is None
assert docEditor.isReadOnly() is True
docFooter.aShowTabsNSpaces.setChecked(False)
assert CONFIG.editShowTabsNSpaces is False
assert not docEditor._qDocument.defaultTextOption().flags() & QTextOption.Flag.ShowTabsAndSpaces
assert docEditor.isReadOnly() is True
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_LoadText(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, ipsumText: list[str], mockRnd: MockRnd):
"""Test loading text into the editor."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
longText = "### Lorem Ipsum\n\n{0}".format("\n\n".join(ipsumText * 20))
docEditor.replaceText(longText)
nwGUI.docPanes.saveDocument()
nwGUI.docPanes.closeDocument()
# Invalid handle
assert docEditor.loadText("abcdefghijklm") is False
# Regular open
assert docEditor.loadText(C.hSceneDoc) is True
# RTL projects still use automatic direction in the editor
SHARED.project.setProjectLang(None, True)
assert docEditor.loadText(C.hSceneDoc) is True
assert docEditor._qDocument.defaultTextOption().textDirection() == Qt.LayoutDirection.LayoutDirectionAuto
# Regular open, with line number (1 indexed)
assert docEditor.loadText(C.hSceneDoc, tLine=4) is True
cursPos = docEditor.getCursorPosition()
assert docEditor._qDocument.findBlock(cursPos).blockNumber() == 3
# Load empty document
docEditor.replaceText("")
nwGUI.docPanes.saveDocument()
assert docEditor.loadText(C.hSceneDoc) is True
assert docEditor.toPlainText() == ""
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_SaveText(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test saving text from the editor."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
longText = "### Lorem Ipsum\n\n{0}".format("\n\n".join(ipsumText))
docEditor.replaceText(longText)
# Missing item
nwItem = docEditor._nwItem
docEditor._nwItem = None
assert docEditor.saveText() is False
docEditor._nwItem = nwItem
# Unknown handle
docEditor._docHandle = "0123456789abcdef"
assert docEditor.saveText() is False
docEditor._docHandle = C.hSceneDoc
# Cause error when saving
with monkeypatch.context() as mp:
mp.setattr("builtins.open", causeOSError)
assert docEditor.saveText() is False
assert "Could not save document." in caplog.text
# Change header level
assert SHARED.project.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT # type: ignore
docEditor.replaceText(longText[1:])
assert docEditor.saveText() is True
assert SHARED.project.tree[C.hSceneDoc].itemLayout == nwItemLayout.DOCUMENT # type: ignore
# Regular save
assert docEditor.saveText() is True
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_SaveTextEdgeCases(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: 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(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.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
writeFile(Path(nwDocument.fileLocation), "Changed outside of novelWriter")
docEditor.setDocumentChanged(True)
assert docEditor.saveText() is True
assert docEditor.docChanged is False
@pytest.mark.gui
def testGuiDocEditor_DragAndDrop(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
mockRnd: MockRnd,
):
"""Test drag and drop in the editor."""
docEditor = nwGUI.docEditor
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hTitlePage) is True
assert docEditor.docHandle == C.hTitlePage
viewport = docEditor.viewport()
assert viewport is not None
middle = viewport.rect().center()
action = Qt.DropAction.MoveAction
mouse = Qt.MouseButton.NoButton
model = SHARED.project.tree.model
docMime = model.mimeData([model.indexFromHandle(C.hSceneDoc)])
noneMime = QMimeData()
noneMime.setData("plain/text", b"")
assert decodeMimeHandles(docMime) == [C.hSceneDoc]
# Drag Enter
mockEnter = MagicMock()
docEvent = QDragEnterEvent(middle, action, docMime, mouse, QtModNone)
noneEvent = QDragEnterEvent(middle, action, noneMime, mouse, QtModNone)
with monkeypatch.context() as mp:
mp.setattr(QTextEdit, "dragEnterEvent", mockEnter)
# Document Enter
docEditor.dragEnterEvent(docEvent)
assert docEvent.isAccepted() is True
assert mockEnter.call_count == 0
# Regular Enter
docEditor.dragEnterEvent(noneEvent)
assert mockEnter.call_count == 1
# Drag Move
mockMove = MagicMock()
docEvent = QDragMoveEvent(middle, action, docMime, mouse, QtModNone)
noneEvent = QDragMoveEvent(middle, action, noneMime, mouse, QtModNone)
with monkeypatch.context() as mp:
mp.setattr(QTextEdit, "dragMoveEvent", mockMove)
# Document Move
docEditor.dragMoveEvent(docEvent)
assert docEvent.isAccepted() is True
assert mockMove.call_count == 0
# Regular Move
docEditor.dragMoveEvent(noneEvent)
assert mockMove.call_count == 1
# Drop
middle = QPointF(viewport.rect().center())
mockDrop = MagicMock()
docEvent = QDropEvent(middle, action, docMime, mouse, QtModNone)
noneEvent = QDropEvent(middle, action, noneMime, mouse, QtModNone)
with monkeypatch.context() as mp:
mp.setattr(QTextEdit, "dropEvent", mockDrop)
# Document Drop
docEditor.dropEvent(docEvent)
assert mockDrop.call_count == 0
assert docEditor.docHandle == C.hSceneDoc
# Regular Move
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 testGuiDocEditor_MetaData(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test extracting various meta data and other values."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
# Get Text
# This should replace line and paragraph separators, but preserve
# non-breaking spaces.
newText = "### New Scene\u2029\u2029Some\u2028text.\u2029More\u00a0text.\u2029"
docEditor.replaceText(newText)
assert docEditor.getText() == ("### New Scene\n\nSome\ntext.\nMore\u00a0text.\n")
# Check Properties
assert docEditor.docChanged is True
assert docEditor.docHandle == C.hSceneDoc
assert docEditor.lastActive > 0.0
assert docEditor.isEmpty is False
# Cursor Position
docEditor.setCursorPosition(10)
assert docEditor.getCursorPosition() == 10
assert SHARED.project.tree[C.hSceneDoc].cursorPos != 10 # type: ignore
docEditor.saveCursorPosition()
assert SHARED.project.tree[C.hSceneDoc].cursorPos == 10 # type: ignore
docEditor.setCursorLine(None)
assert docEditor.getCursorPosition() == 10
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):
docEditor.setDocumentChanged(True)
assert docEditor._docChanged is True
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_ContextMenu(
monkeypatch: pytest.MonkeyPatch,
qtbot: QtBot,
nwGUI: GuiMain,
projPath: Path,
mockRnd: MockRnd,
):
"""Test the editor context menu."""
monkeypatch.setattr(QMenu, "exec", lambda *a: None)
monkeypatch.setattr(QMenu, "setParent", lambda *a: None)
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
sceneItem = SHARED.project.tree[C.hSceneDoc]
assert sceneItem is not None
docText = "### A Scene\n\n@pov: Jane\n\nSome text ...\n\n... and a link to http://example.com\n\n"
docEditor.setPlainText(docText)
assert docEditor.getText() == docText
# Rename Item from Heading
ctxMenu = getMenuForPos(docEditor, 1)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Set as Document Name",
"Paste",
"Select All",
"Select Word",
"Select Paragraph",
"More Actions",
]
with monkeypatch.context() as mp:
mp.setattr(GuiEditLabel, "getLabel", lambda a, text: (text, True))
assert sceneItem.itemName == "New Scene"
ctxMenu.actions()[0].trigger()
assert sceneItem.itemName == "A Scene"
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Open Link
ctxMenu = getMenuForPos(docEditor, 63)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Open URL",
"Paste",
"Select All",
"Select Word",
"Select Paragraph",
"More Actions",
]
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Create Character
ctxMenu = getMenuForPos(docEditor, 21)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Create Note for Tag",
"Paste",
"Select All",
"Select Word",
"Select Paragraph",
"More Actions",
]
ctxMenu.actions()[0].trigger()
janeItem = SHARED.project.tree["0000000000011"]
assert janeItem is not None
assert janeItem.itemName == "Jane"
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Follow Character Tag
ctxMenu = getMenuForPos(docEditor, 21)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"View Tag Source",
"Edit Tag Source",
"Paste",
"Select All",
"Select Word",
"Select Paragraph",
"More Actions",
]
ctxMenu.actions()[0].trigger()
assert nwGUI.docViewer.docHandle == "0000000000011"
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Select Word
ctxMenu = getMenuForPos(docEditor, 31)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Paste",
"Select All",
"Select Word",
"Select Paragraph",
"More Actions",
]
ctxMenu.actions()[3].trigger()
assert docEditor.textCursor().selectedText() == "text"
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Select Paragraph
ctxMenu = getMenuForPos(docEditor, 31)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Paste",
"Select All",
"Select Word",
"Select Paragraph",
"More Actions",
]
ctxMenu.actions()[4].trigger()
assert docEditor.textCursor().selectedText() == "Some text ..."
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Select All
ctxMenu = getMenuForPos(docEditor, 31)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Paste",
"Select All",
"Select Word",
"Select Paragraph",
"More Actions",
]
ctxMenu.actions()[2].trigger()
assert docEditor.textCursor().selectedText() == docEditor._qDocument.toRawText()
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Copy Text
clipboard = QApplication.clipboard()
assert clipboard is not None
ctxMenu = getMenuForPos(docEditor, 31, True)
assert ctxMenu is not None
assert docEditor.textCursor().selectedText() == "text"
actions = [x.text() for x in ctxMenu.actions() if x.text()]
assert actions == [
"Cut",
"Copy",
"Paste",
"Select All",
"Select Word",
"Select Paragraph",
"More Actions",
]
clipboard.clear()
ctxMenu.actions()[1].trigger()
assert clipboard.text(QClipboard.Mode.Clipboard) == "text"
# Cut Text
clipboard.clear()
ctxMenu.actions()[0].trigger()
assert clipboard.text(QClipboard.Mode.Clipboard) == "text"
assert "text" not in docEditor.getText()
# Paste Text
ctxMenu.actions()[2].trigger()
assert docEditor.getText() == docText
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_SpellChecking(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test the document spell checker."""
monkeypatch.setattr(QMenu, "exec", lambda *a: None)
monkeypatch.setattr(QMenu, "setParent", lambda *a: None)
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
# The test must not depend on which dictionaries are available on
# the host system, so all words are accepted from the start, and
# the spell check worker must run synchronously
monkeypatch.setattr(SpellEnchant, "checkWord", lambda *a: True)
monkeypatch.setattr(SHARED, "runInThreadPool", lambda r: r.run())
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
text = "### A Scene\n\n{0}".format("\n\n".join(ipsumText))
docEditor.replaceText(text)
# Toggle State
# ============
# No language set
SHARED.spelling._language = None
SHARED.project.data.setSpellCheck(False)
docEditor.toggleSpellCheck(True)
assert SHARED.project.data.spellCheck is False
# No spell checker available
SHARED.spelling._language = "en"
docEditor.toggleSpellCheck(True)
assert SHARED.project.data.spellCheck is True
CONFIG.hasEnchant = False
docEditor.toggleSpellCheck(True)
assert SHARED.project.data.spellCheck is False
CONFIG.hasEnchant = True
docEditor.toggleSpellCheck(True)
assert SHARED.project.data.spellCheck is True
# Plain Toggle
docEditor.toggleSpellCheck(None)
assert SHARED.project.data.spellCheck is False
# Run SpellCheck
# ==============
SHARED.project.data.setSpellCheck(True)
LORAX = "Lorax\U0001f03a"
cursor = docEditor.textCursor()
cursor.setPosition(16)
data = cursor.block().userData()
assert cursor.block().text().startswith("Lorem")
assert isinstance(data, TextBlockData)
data._spellErrors = [(0, 5, "Lorem")]
# No known position
assert docEditor._qDocument.spellErrorAtPos(-1) == ("", -1, -1, [])
# Multiple entries: the first doesn't match, the second does
blockPos = cursor.block().position()
data._spellErrors = [(0, 5, "Lorem"), (6, 11, "ipsum")]
with monkeypatch.context() as mp:
mp.setattr(SpellEnchant, "suggestWords", lambda *a: [])
assert docEditor._qDocument.spellErrorAtPos(blockPos + 8) == ("ipsum", 6, 11, [])
# No entry matches the given position
assert docEditor._qDocument.spellErrorAtPos(blockPos + 20) == ("", -1, -1, [])
# Meta data lookup follows the same pattern
data._metaData = [(0, 5, "Lorem", "url"), (6, 11, "ipsum", "url")]
assert docEditor._qDocument.metaDataAtPos(-1) == ("", "")
assert docEditor._qDocument.metaDataAtPos(blockPos + 8) == ("ipsum", "url")
assert docEditor._qDocument.metaDataAtPos(blockPos + 20) == ("", "")
data._spellErrors = [(0, 5, "Lorem")]
# The spell error should be rendered as an extra selection
docEditor._updateCheckSelections()
spellSel = [
s
for s in docEditor.extraSelections()
if s.format.underlineStyle() == QTextCharFormat.UnderlineStyle.SpellCheckUnderline
]
assert len(spellSel) == 1
assert spellSel[0].cursor.selectionStart() == blockPos
assert spellSel[0].cursor.selectionEnd() == blockPos + 5
# With spell check disabled, the selections should be cleared
SHARED.project.data.setSpellCheck(False)
docEditor._updateCheckSelections()
assert docEditor._spellSelections == []
SHARED.project.data.setSpellCheck(True)
# With Suggestion
with monkeypatch.context() as mp:
mp.setattr(SpellEnchant, "suggestWords", lambda *a: [LORAX])
suggestion = f"{nwUnicode.U_ENDASH} {LORAX}"
ctxMenu = getMenuForPos(docEditor, 16)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions()]
assert "Spelling Suggestion(s)" in actions
assert suggestion in actions
ctxMenu.actions()[actions.index(suggestion)].trigger()
QApplication.processEvents()
assert docEditor.getText() == text.replace("Lorem", LORAX, 1)
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Update Entry
data._spellErrors = [(0, 7, LORAX)]
# Without Suggestion
with monkeypatch.context() as mp:
mp.setattr(SpellEnchant, "suggestWords", lambda *a: [])
ctxMenu = getMenuForPos(docEditor, 16)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions()]
assert f"{nwUnicode.U_ENDASH} No Suggestions" in actions
assert docEditor.getText() == text.replace("Lorem", LORAX, 1)
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# Add to Dictionary
with monkeypatch.context() as mp:
mp.setattr(SpellEnchant, "suggestWords", lambda *a: [])
ctxMenu = getMenuForPos(docEditor, 16)
assert ctxMenu is not None
actions = [x.text() for x in ctxMenu.actions()]
assert "Ignore Word" in actions
assert "Add Word to Dictionary" in actions
assert LORAX not in SHARED.spelling._userDict
ctxMenu.actions()[actions.index("Ignore Word")].trigger()
assert LORAX not in SHARED.spelling._userDict
ctxMenu.actions()[actions.index("Add Word to Dictionary")].trigger()
assert LORAX in SHARED.spelling._userDict
ctxMenu.setObjectName("")
ctxMenu.deleteLater()
# No spelling error at the clicked position: no spelling actions
with monkeypatch.context() as mp:
mp.setattr(SpellEnchant, "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()
# Editing a block flags it for the debounced spell check
docEditor._dirtyBlocks.clear()
docEditor.setCursorPosition(blockPos + 5)
docEditor.textCursor().insertText("x")
assert docEditor._dirtyBlocks != {}
assert docEditor._timerTextCheck.isActive()
# Running the check dispatches the dirty blocks to the worker
docEditor._dispatchTextCheck()
assert docEditor._dirtyBlocks == {}
assert docEditor._checkJob is None
# An error under the caret is not underlined
data = docEditor.textCursor().block().userData()
assert isinstance(data, TextBlockData)
data._spellErrors = [(0, 5, "Lorem")]
docEditor.setCursorPosition(blockPos + 3)
docEditor._updateCheckSelections()
assert docEditor._suppressed is True
assert docEditor._spellSelections == []
# Moving the caret out of the word restores the underline
docEditor.setCursorPosition(blockPos + 10)
docEditor._updateCheckSelections()
assert docEditor._suppressed is False
assert len(docEditor._spellSelections) == 1
# Background Spell Pass
# =====================
with monkeypatch.context() as mp:
mp.setattr("novelwriter.constants.nwConst.CHECK_PASS_CHUNK", 2)
# A full pass runs chunked worker jobs until the document is
# done, which with a synchronous worker completes immediately
docEditor._beginCheckPass()
assert docEditor._checkPassPos is None
assert docEditor._checkJob is None
# A full spell check notifies when the pass completes
docEditor.spellCheckDocument()
assert docEditor._spellPassNotify is False
# Results from a cancelled job are dropped
docEditor._textCheckResults(docEditor._checkJobId + 1, [])
assert docEditor._checkJob is None
# Results for blocks modified while checking are discarded
block = docEditor.textCursor().block()
data = block.userData()
assert isinstance(data, TextBlockData)
data._spellErrors = []
docEditor._checkJobId += 1
docEditor._checkJob = (docEditor._checkJobId, [(block, data, data.revision - 1)])
docEditor._textCheckResults(docEditor._checkJobId, [(0, [(0, 5, "wrong")], [])])
assert data.spellErrors == []
assert docEditor._checkJob is None
# No new dispatch is made while a job is still in flight
docEditor._dirtyBlocks[block.blockNumber()] = block
docEditor._checkJob = (docEditor._checkJobId, [])
docEditor._dispatchTextCheck()
assert docEditor._dirtyBlocks != {}
docEditor._checkJob = None
docEditor._dirtyBlocks.clear()
# qtbot.stop()
def testGuiDocEditor_FormatCheckText():
"""Test the raw multi-space and trailing-space checker function."""
# Multiple runs of multiple spaces
assert formatCheckText("one two three", 0, None) == [(3, 5, "multi"), (8, 11, "multi")]
# Trailing space only
assert formatCheckText("one two ", 0, None) == [(7, 8, "trail")]
# A trailing run is reported as both kinds
assert formatCheckText("one two ", 0, None) == [(3, 5, "multi"), (8, 10, "multi"), (8, 10, "trail")]
# No errors
assert formatCheckText("one two three", 0, None) == []
# The offset is respected
assert formatCheckText("one two", 4, None) == []
# Positions are translated through a UTF-16 map
text = "a\U0001f605 b "
utf16Map = utf16CharMap(text)
assert formatCheckText(text, 0, utf16Map) == [(3, 5, "multi"), (6, 7, "trail")]
# A URL padded to spaces of equal length by TextBlockData.processText
# must not be mistaken for a real run of multiple spaces, or every
# comment/text line containing a URL would be flagged
data = TextBlockData()
text = "See http://example.com for details. "
data.processText(text, 0, None)
assert formatCheckText(data._text, 0, None) == [(3, 23, "multi"), (35, 37, "multi"), (35, 37, "trail")]
assert data.formatCheck() == [(35, 37, "multi"), (35, 37, "trail")]
# A cached spell/format error may hold a position past the end of a
# shortened block, e.g. right after an undo removes previously typed
# text. processText() must drop the stale cache immediately, rather
# than waiting for the debounced recheck, or the stale, out-of-range
# positions can briefly render into the following block
data.setSpellErrors([(0, 100, "wrong")])
data.setFormatErrors([(0, 100, "multi")])
data.processText("short", 0, None)
assert data.spellErrors == []
assert data.formatErrors == []
def testGuiDocEditor_ProcessTextLinks():
"""Test that TextBlockData detects Markdown links, and does not
also flag the URL they contain as a separate bare URL.
"""
data = TextBlockData()
# A bare URL is kept in the spell text, and recorded as a "url"
text = "See http://example.com for details."
data.processText(text, 0, None)
assert data._text == "See for details."
assert data.metaData == [(4, 22, "http://example.com", "url")]
# A Markdown link has its brackets and URL blanked from the spell
# text, keeping only the link text, and is recorded as a "link"
# with the target URL
text = "See [a link](http://example.com) for details."
data.processText(text, 0, None)
assert data._text == "See a link for details."
assert data.metaData == [(4, 32, "http://example.com", "link")]
# The URL inside a Markdown link must not also be picked up as a
# separate bare URL, while an unrelated bare URL still is
text = "See [a link](http://example.com) and http://other.com too."
data.processText(text, 0, None)
assert data.metaData == [
(4, 32, "http://example.com", "link"),
(37, 53, "http://other.com", "url"),
]
# A Markdown link's positions are translated through a UTF-16 map
# when the block contains a 4 byte Unicode character
text = "\U0001f605 [a link](http://example.com) done."
utf16Map = utf16CharMap(text)
data.processText(text, 0, utf16Map)
assert data.metaData == [(3, 31, "http://example.com", "link")]
# A bare "file://" URL has no "http" substring, so the bare URL
# check must not be gated on that alone, or it would be missed
text = "See file:///tmp/note.txt here."
data.processText(text, 0, None)
assert data.metaData == [(4, 24, "file:///tmp/note.txt", "url")]
# A Markdown link's target need not be a URL with a "://" scheme,
# so the link check must not be gated on that either
text = "See [reference](internal-note) here."
data.processText(text, 0, None)
assert data.metaData == [(4, 30, "internal-note", "link")]
@pytest.mark.gui
def testGuiDocEditor_FormatChecking(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
mockRnd: MockRnd,
):
"""Test the document multi-space and trailing-space checker."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
monkeypatch.setattr(SHARED, "runInThreadPool", lambda r: r.run())
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
text = "### A Scene\n\nA double space, and a trailing space \n"
docEditor.replaceText(text)
blockPos = text.index("A double")
# With the feature disabled, no markers are generated
CONFIG.showMultiSpaces = False
docEditor._beginCheckPass()
assert docEditor._formatSelections == []
# With the feature enabled, both errors are found and rendered as
# extra selections with the same format, regardless of kind
CONFIG.showMultiSpaces = True
docEditor._beginCheckPass()
data = docEditor._qDocument.findBlock(blockPos).userData()
assert isinstance(data, TextBlockData)
assert data.formatErrors == [(1, 3, "multi"), (37, 38, "trail")]
formatSel = sorted(
(
s
for s in docEditor.extraSelections()
if s.format.underlineStyle() == QTextCharFormat.UnderlineStyle.SingleUnderline
),
key=lambda s: s.cursor.selectionStart(),
)
assert len(formatSel) == 2
assert formatSel[0].cursor.selectionStart() == blockPos + 1
assert formatSel[0].cursor.selectionEnd() == blockPos + 3
assert formatSel[1].cursor.selectionStart() == blockPos + 37
assert formatSel[1].cursor.selectionEnd() == blockPos + 38
# A trailing space right under the caret is not yet flagged, since
# it's a natural, transient state while the line is still being
# typed. Other errors in the same block remain visible
docEditor.setCursorPosition(blockPos + 38)
docEditor._updateCheckSelections()
assert docEditor._suppressed is True
assert len(docEditor._formatSelections) == 1
assert docEditor._formatSelections[0].cursor.selectionStart() == blockPos + 1
# Moving the caret elsewhere in the same block, away from the
# trailing space itself, does not suppress it
docEditor.setCursorPosition(blockPos + 5)
docEditor._updateCheckSelections()
assert docEditor._suppressed is False
assert len(docEditor._formatSelections) == 2
# Toggling the feature back off clears the markers
CONFIG.showMultiSpaces = False
docEditor._beginCheckPass()
assert docEditor._formatSelections == []
@pytest.mark.gui
def testGuiDocEditor_UpdateSyntaxColors(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
mockRnd: MockRnd,
):
"""A theme change must immediately re-colour any extra selections
that already exist, rather than waiting for the cursor to move or
for the spell/format check cache to be invalidated by an edit.
"""
monkeypatch.setattr(SHARED, "runInThreadPool", lambda r: r.run())
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
text = "### A Scene\n\nA double space, and a trailing space \n"
docEditor.replaceText(text)
blockPos = text.index("A double")
# Build a format error selection
CONFIG.showMultiSpaces = True
docEditor._beginCheckPass()
data = docEditor._qDocument.findBlock(blockPos).userData()
assert isinstance(data, TextBlockData)
assert data.formatErrors != []
# Build a spell error selection
SHARED.project.data.setSpellCheck(True)
data._spellErrors = [(0, 1, "A")]
docEditor._updateCheckSelections()
assert docEditor._spellSelections != []
# Build a search match selection
docEditor.highlightSearchSelections([blockPos], [blockPos + 1])
assert len(docEditor._searchSelections) == 1
# Build the current line highlight selection
CONFIG.lineHighlight = True
docEditor.setCursorPosition(blockPos)
assert docEditor._selection.cursor.position() == blockPos
# Change the theme colours and refresh the syntax colours only,
# without moving the cursor or editing the document
SHARED.theme.syntaxTheme.error = QColor(1, 2, 3)
SHARED.theme.syntaxTheme.spell = QColor(10, 11, 12)
SHARED.theme.searchCol = QColor(4, 5, 6)
SHARED.theme.syntaxTheme.line = QColor(7, 8, 9)
docEditor.updateSyntaxColors()
formatSel = next(
s
for s in docEditor.extraSelections()
if s.format.underlineStyle() == QTextCharFormat.UnderlineStyle.SingleUnderline
)
assert formatSel.format.underlineColor() == QColor(1, 2, 3)
spellSel = next(
s
for s in docEditor.extraSelections()
if s.format.underlineStyle() == QTextCharFormat.UnderlineStyle.SpellCheckUnderline
)
assert spellSel.format.underlineColor() == QColor(10, 11, 12)
searchSel = next(
s
for s in docEditor.extraSelections()
if s.cursor.selectionStart() == blockPos and s.cursor.selectionEnd() == blockPos + 1
)
assert searchSel.format.background().color() == QColor(4, 5, 6)
lineSel = docEditor.extraSelections()[0]
assert lineSel.cursor.hasSelection() is False
assert lineSel.format.background().color() == QColor(7, 8, 9)
@pytest.mark.gui
def testGuiDocEditor_UpdateReplaceKeys(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that auto-replace key changes refresh the highlighter."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
syntax = docEditor._qDocument.syntaxHighlighter
# No document open, nothing is updated
nwGUI.docPanes.closeDocument()
SHARED.project.data.setAutoReplace({"Name": "Bob"}, False)
docEditor.updateReplaceKeys()
assert syntax._replaceKeys == frozenset()
# Opening a document loads the keys
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
assert syntax._replaceKeys == frozenset({"Name"})
# Changed keys are picked up
SHARED.project.data.setAutoReplace({"Name": "Bob", "Place": "Here"}, False)
docEditor.updateReplaceKeys()
assert syntax._replaceKeys == frozenset({"Name", "Place"})
@pytest.mark.gui
def testGuiDocEditor_Actions(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, ipsumText: list[str], mockRnd: MockRnd):
"""Test the document actions. This is not an extensive test of the
action features, just that the actions are actually called. The
various action features are tested when their respective functions
are tested.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
text = "### A Scene\n\n{0}".format("\n\n".join(ipsumText))
docEditor.replaceText(text)
doc = docEditor._qDocument
# Check that the reset colour state works (used for action errors)
docEditor.changeFocusState(True)
assert docEditor.docHeader.itemTitle._state == nwState.NORMAL
docEditor.docHeader.itemTitle.setColorState(nwState.ERROR)
docEditor.docHeader._resetColourState()
assert docEditor.docHeader.itemTitle._state == nwState.NORMAL
# Select/Cut/Copy/Paste/Undo/Redo
# ===============================
clipboard = QApplication.clipboard()
assert clipboard is not None
clipboard.clear()
# Select All
assert docEditor.docAction(nwDocAction.SEL_ALL) is True
cursor = docEditor.textCursor()
assert cursor.hasSelection() is True
assert cursor.selectedText() == text.replace("\n", "\u2029")
cursor.clearSelection()
# Select Paragraph
docEditor.setCursorPosition(1000)
assert docEditor.getCursorPosition() == 1000
assert docEditor.docAction(nwDocAction.SEL_PARA) is True
cursor = docEditor.textCursor()
assert cursor.selectedText() == ipsumText[1]
# Cut Selected Text
docEditor.replaceText(text)
docEditor.setCursorPosition(1000)
assert docEditor.docAction(nwDocAction.SEL_PARA) is True
assert docEditor.docAction(nwDocAction.CUT) is True
newText = docEditor.getText()
newPara = list(filter(str.strip, newText.split("\n")))
assert newPara[0] == "### A Scene"
assert newPara[1] == ipsumText[0]
assert newPara[2] == ipsumText[2]
assert newPara[3] == ipsumText[3]
assert newPara[4] == ipsumText[4]
# Paste Back In
assert docEditor.docAction(nwDocAction.PASTE) is True
assert docEditor.getText() == text
# Copy Next Paragraph
docEditor.replaceText(text)
docEditor.setCursorPosition(1500)
assert docEditor.docAction(nwDocAction.SEL_PARA) is True
assert docEditor.docAction(nwDocAction.COPY) is True
# Paste at End
docEditor.setCursorPosition(doc.characterCount())
cursor = docEditor.textCursor()
cursor.insertBlock()
cursor.insertBlock()
assert docEditor.docAction(nwDocAction.PASTE) is True
newText = docEditor.getText()
newPara = list(filter(str.strip, newText.split("\n")))
assert newPara[5] == ipsumText[4]
assert newPara[6] == ipsumText[2]
clipboard.clear()
# Emphasis/Undo/Redo
# ==================
text = f"### A Scene\n\n{ipsumText[0]}"
docEditor.replaceText(text)
# Emphasis
docEditor.setCursorPosition(50)
assert docEditor.docAction(nwDocAction.MD_ITALIC) is True
assert docEditor.getText() == text.replace("consectetur", "_consectetur_")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.MD_ITALIC) is False
# Strong
docEditor.setCursorPosition(50)
assert docEditor.docAction(nwDocAction.MD_BOLD) is True
assert docEditor.getText() == text.replace("consectetur", "**consectetur**")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.MD_BOLD) is False
# Strikeout
docEditor.setCursorPosition(50)
assert docEditor.docAction(nwDocAction.MD_STRIKE) is True
assert docEditor.getText() == text.replace("consectetur", "~~consectetur~~")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.MD_STRIKE) is False
# Mark
docEditor.setCursorPosition(50)
assert docEditor.docAction(nwDocAction.MD_MARK) is True
assert docEditor.getText() == text.replace("consectetur", "==consectetur==")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.MD_MARK) is False
# Redo
assert docEditor.docAction(nwDocAction.REDO) is True
assert docEditor.getText() == text.replace("consectetur", "==consectetur==")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
# Shortcodes
# ==========
text = f"### A Scene\n\n{ipsumText[0]}"
docEditor.replaceText(text)
# Italic
docEditor.setCursorPosition(46)
assert docEditor.docAction(nwDocAction.SC_ITALIC) is True
assert docEditor.getText() == text.replace("consectetur", "[i]consectetur[/i]")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.SC_ITALIC) is False
# Bold
docEditor.setCursorPosition(46)
assert docEditor.docAction(nwDocAction.SC_BOLD) is True
assert docEditor.getText() == text.replace("consectetur", "[b]consectetur[/b]")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.SC_BOLD) is False
# Strikethrough
docEditor.setCursorPosition(46)
assert docEditor.docAction(nwDocAction.SC_STRIKE) is True
assert docEditor.getText() == text.replace("consectetur", "[s]consectetur[/s]")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.SC_STRIKE) is False
# Underline
docEditor.setCursorPosition(46)
assert docEditor.docAction(nwDocAction.SC_ULINE) is True
assert docEditor.getText() == text.replace("consectetur", "[u]consectetur[/u]")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.SC_ULINE) is False
# Mark
docEditor.setCursorPosition(46)
assert docEditor.docAction(nwDocAction.SC_MARK) is True
assert docEditor.getText() == text.replace("consectetur", "[m]consectetur[/m]")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.SC_MARK) is False
# Superscript
docEditor.setCursorPosition(46)
assert docEditor.docAction(nwDocAction.SC_SUP) is True
assert docEditor.getText() == text.replace("consectetur", "[sup]consectetur[/sup]")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.SC_SUP) is False
# Subscript
docEditor.setCursorPosition(46)
assert docEditor.docAction(nwDocAction.SC_SUB) is True
assert docEditor.getText() == text.replace("consectetur", "[sub]consectetur[/sub]")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.SC_SUB) is False
# RTL projects get an RTL mark when a shortcode would set the direction
SHARED.project.setProjectLang(None, True)
word = "متن"
text = f"### A Scene\n\n{word} {word}\n\n% {word}"
docEditor.replaceText(text)
docEditor.setCursorPosition(14)
assert docEditor.docAction(nwDocAction.SC_ITALIC) is True
assert docEditor.getText() == f"### A Scene\n\n\u200f[i]{word}[/i] {word}\n\n% {word}"
assert docEditor.docAction(nwDocAction.UNDO) is True
docEditor.setCursorPosition(18) # Not needed after a strong character
assert docEditor.docAction(nwDocAction.SC_ITALIC) is True
assert docEditor.getText() == f"### A Scene\n\n{word} [i]{word}[/i]\n\n% {word}"
assert docEditor.docAction(nwDocAction.UNDO) is True
docEditor.setCursorPosition(25) # Goes after the comment prefix
assert docEditor.docAction(nwDocAction.SC_ITALIC) is True
assert docEditor.getText() == f"### A Scene\n\n{word} {word}\n\n% \u200f[i]{word}[/i]"
assert docEditor.docAction(nwDocAction.UNDO) is True
SHARED.project.setProjectLang(None, False)
# Quotes
# ======
text = f"### A Scene\n\n{ipsumText[0]}"
docEditor.replaceText(text)
# Add Single Quotes
docEditor.setCursorPosition(50)
assert docEditor.docAction(nwDocAction.S_QUOTE) is True
assert docEditor.getText() == text.replace("consectetur", "\u2018consectetur\u2019")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
# Add Double Quotes
docEditor.setCursorPosition(50)
assert docEditor.docAction(nwDocAction.D_QUOTE) is True
assert docEditor.getText() == text.replace("consectetur", "\u201cconsectetur\u201d")
assert docEditor.docAction(nwDocAction.UNDO) is True
assert docEditor.getText() == text
# Replace Single Quotes
repText = text.replace("consectetur", "'consectetur'")
docEditor.replaceText(repText)
assert docEditor.docAction(nwDocAction.SEL_ALL) is True
assert docEditor.docAction(nwDocAction.REPL_SNG) is True
assert docEditor.getText() == text.replace("consectetur", "\u2018consectetur\u2019")
# Replace Double Quotes
repText = text.replace("consectetur", '"consectetur"')
docEditor.replaceText(repText)
assert docEditor.docAction(nwDocAction.SEL_ALL) is True
assert docEditor.docAction(nwDocAction.REPL_DBL) is True
assert docEditor.getText() == text.replace("consectetur", "\u201cconsectetur\u201d")
# Remove Line Breaks
# ==================
text = f"### A Scene\n\n{ipsumText[0]}"
repText = text[:100] + text[100:].replace(" ", "\n", 3)
docEditor.replaceText(repText)
assert docEditor.docAction(nwDocAction.RM_BREAKS) is True
assert docEditor.getText().strip() == text.strip()
# Format Block
# ============
text = "## Scene Title\n\nScene text.\n\n"
docEditor.replaceText(text)
# Header 1
docEditor.setCursorPosition(0)
assert docEditor.docAction(nwDocAction.BLOCK_H1) is True
assert docEditor.getText() == "# Scene Title\n\nScene text.\n\n"
# Header 2
docEditor.setCursorPosition(0)
assert docEditor.docAction(nwDocAction.BLOCK_H2) is True
assert docEditor.getText() == "## Scene Title\n\nScene text.\n\n"
# Header 3
docEditor.setCursorPosition(0)
assert docEditor.docAction(nwDocAction.BLOCK_H3) is True
assert docEditor.getText() == "### Scene Title\n\nScene text.\n\n"
# Header 4
docEditor.setCursorPosition(0)
assert docEditor.docAction(nwDocAction.BLOCK_H4) is True
assert docEditor.getText() == "#### Scene Title\n\nScene text.\n\n"
# Comment
docEditor.setCursorPosition(20)
assert docEditor.docAction(nwDocAction.BLOCK_COM) is True
assert docEditor.getText() == "#### Scene Title\n\n% Scene text.\n\n"
# Ignore Text
docEditor.setCursorPosition(20)
assert docEditor.docAction(nwDocAction.BLOCK_IGN) is True
assert docEditor.getText() == "#### Scene Title\n\n%~ Scene text.\n\n"
# Text
docEditor.setCursorPosition(20)
assert docEditor.docAction(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "#### Scene Title\n\nScene text.\n\n"
# Align Left
docEditor.setCursorPosition(20)
assert docEditor.docAction(nwDocAction.ALIGN_L) is True
assert docEditor.getText() == "#### Scene Title\n\nScene text. <<\n\n"
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.ALIGN_L) is False
# Align Right
docEditor.setCursorPosition(20)
assert docEditor.docAction(nwDocAction.ALIGN_R) is True
assert docEditor.getText() == "#### Scene Title\n\n>> Scene text.\n\n"
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.ALIGN_R) is False
# Align Centre
docEditor.setCursorPosition(20)
assert docEditor.docAction(nwDocAction.ALIGN_C) is True
assert docEditor.getText() == "#### Scene Title\n\n>> Scene text. <<\n\n"
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.ALIGN_C) is False
# Indent Left
docEditor.setCursorPosition(20)
assert docEditor.docAction(nwDocAction.INDENT_L) is True
assert docEditor.getText() == "#### Scene Title\n\n> Scene text.\n\n"
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.INDENT_L) is False
# Indent Right
docEditor.setCursorPosition(20)
assert docEditor.docAction(nwDocAction.INDENT_R) is True
assert docEditor.getText() == "#### Scene Title\n\n> Scene text. <\n\n"
docEditor.setCursorPosition(0) # Blocked on headings
assert docEditor.docAction(nwDocAction.INDENT_R) is False
# Text (Reset)
docEditor.setCursorPosition(20)
assert docEditor.docAction(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "#### Scene Title\n\nScene text.\n\n"
# Invalid Actions
# ===============
# No Document Handle
docEditor._docHandle = None
assert docEditor.docAction(nwDocAction.BLOCK_TXT) is False
docEditor._docHandle = C.hSceneDoc
# Wrong Action Type
assert docEditor.docAction(None) is False # type: ignore
# Unknown Action
assert docEditor.docAction(nwDocAction.NO_ACTION) is False
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_Zoom(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test zooming the editor font via docAction and Ctrl+Scroll, and
resetting it back to the configured font size.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
basePt = docEditor.font().pointSizeF()
# Zoom in and out via docAction (menu and shortcut path)
assert docEditor.docAction(nwDocAction.ZOOM_IN) is True
assert docEditor.font().pointSizeF() == basePt + 1
assert docEditor.docAction(nwDocAction.ZOOM_OUT) is True
assert docEditor.font().pointSizeF() == basePt
assert docEditor.docAction(nwDocAction.ZOOM_IN) is True
assert docEditor.docAction(nwDocAction.ZOOM_IN) is True
assert docEditor.font().pointSizeF() == basePt + 2
assert docEditor.docAction(nwDocAction.ZOOM_RESET) is True
assert docEditor.font().pointSizeF() == basePt
# Zoom with the footer buttons, and show the font size in the footer
docFooter = docEditor.docFooter
assert docFooter.zoomLabel.text().startswith(f"{basePt:n}")
docFooter.zoomInButton.click()
assert docEditor.font().pointSizeF() == basePt + 1
assert docFooter.zoomLabel.text().startswith(f"{basePt + 1:n}")
docFooter.zoomOutButton.click()
assert docEditor.font().pointSizeF() == basePt
assert docFooter.zoomLabel.text().startswith(f"{basePt:n}")
# Clicking the zoom label resets the zoom
docFooter.zoomInButton.click()
docFooter.zoomInButton.click()
assert docEditor.font().pointSizeF() == basePt + 2
qtbot.mouseClick(docFooter.zoomLabel, QtMouseLeft)
assert docEditor.font().pointSizeF() == basePt
assert docFooter.zoomLabel.text().startswith(f"{basePt:n}")
# Zoom in and out with Ctrl+Scroll wheel
position = QPointF(10, 10)
zoomInEvent = QWheelEvent(
position,
position,
QPoint(0, 0),
QPoint(0, 120),
Qt.MouseButton.NoButton,
QtModCtrl,
Qt.ScrollPhase.NoScrollPhase,
False,
)
docEditor.wheelEvent(zoomInEvent)
assert docEditor.font().pointSizeF() == basePt + 1
zoomOutEvent = QWheelEvent(
position,
position,
QPoint(0, 0),
QPoint(0, -120),
Qt.MouseButton.NoButton,
QtModCtrl,
Qt.ScrollPhase.NoScrollPhase,
False,
)
docEditor.wheelEvent(zoomOutEvent)
assert docEditor.font().pointSizeF() == basePt
# A no-op Ctrl+Scroll (no vertical delta) does not zoom either way
noopEvent = QWheelEvent(
position,
position,
QPoint(0, 0),
QPoint(0, 0),
Qt.MouseButton.NoButton,
QtModCtrl,
Qt.ScrollPhase.NoScrollPhase,
False,
)
docEditor.wheelEvent(noopEvent)
assert docEditor.font().pointSizeF() == basePt
# A regular (non-Ctrl) wheel scroll does not zoom, and instead falls
# through to the normal scroll handling
scrollEvent = QWheelEvent(
position,
position,
QPoint(0, 0),
QPoint(0, 120),
Qt.MouseButton.NoButton,
QtModNone,
Qt.ScrollPhase.NoScrollPhase,
False,
)
docEditor.wheelEvent(scrollEvent)
assert docEditor.font().pointSizeF() == basePt
# High resolution wheel steps add up to whole points in both directions
for dy, size in ((-40, 0), (-40, 0), (-40, -1), (40, -1), (40, -1), (40, 0)):
hiResEvent = QWheelEvent(
position,
position,
QPoint(0, 0),
QPoint(0, dy),
Qt.MouseButton.NoButton,
QtModCtrl,
Qt.ScrollPhase.NoScrollPhase,
False,
)
docEditor.wheelEvent(hiResEvent)
assert docEditor.font().pointSizeF() == basePt + size
# Zoom is capped at the minimum and maximum size
docEditor.zoomOut(100)
assert docEditor.font().pointSizeF() == nwConst.TEXT_FONT_MIN_SIZE
docEditor.zoomIn(100)
assert docEditor.font().pointSizeF() == nwConst.TEXT_FONT_MAX_SIZE
# Tab stops follow the zoomed font size
tabStop = docEditor._qDocument.defaultTextOption().tabStopDistance()
assert tabStop == CONFIG.tabSize * nwConst.TEXT_FONT_MAX_SIZE * 96.0 / 72.0
@pytest.mark.gui
def testGuiDocEditor_Navigation(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, ipsumText: list[str], mockRnd: MockRnd):
"""Test editor navigation."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor: GuiDocEditor = nwGUI.docEditor
text = "### A Scene\n\n{0}".format("\n\n".join(ipsumText[:3]))
docEditor.replaceText(text)
assert docEditor.textCursor().blockNumber() == 0
steps = [
(0, 0), # No change
(1, 2), # First paragraph
(1, 4), # Second paragraph
(1, 6), # Third paragraph
(1, 6), # Third paragraph, end of document
(-1, 4), # Second paragraph
(-1, 2), # First paragraph
(-1, 0), # Title
(-1, 0), # First paragraph, start of document
]
for step, pos in steps:
docEditor._skipToParagraph(step)
assert docEditor.textCursor().blockNumber() == pos
@pytest.mark.gui
def testGuiDocEditor_ToolBar(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test the document actions. This is not an extensive test of the
action features, just that the actions are actually called. The
various action features are tested when their respective functions
are tested.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
docToolBar = docEditor.docToolBar
text = (
"### A Scene\n\n"
"Text bold one\n\n"
"Text bold two\n\n"
"Text italic one\n\n"
"Text italic two\n\n"
"Text strikethrough one\n\n"
"Text strikethrough two\n\n"
"Text underline one\n\n"
"Text superscript one\n\n"
"Text subscript one\n\n"
)
length = len(text)
docEditor.replaceText(text)
assert len(docEditor.getText()) == length
# Show the ToolBar
assert docToolBar.isVisible() is False
docEditor._toggleToolBarVisibility()
assert docToolBar.isVisible() is True
# Markdown
# ========
# Click Bold
docEditor.setCursorPosition(20)
docToolBar.tbBoldMD.click()
assert len(docEditor.getText()) == length + 4
# Click Italic
docEditor.setCursorPosition(54)
docToolBar.tbItalicMD.click()
assert len(docEditor.getText()) == length + 6
# Click Strikethrough
docEditor.setCursorPosition(90)
docToolBar.tbStrikeMD.click()
assert len(docEditor.getText()) == length + 10
# Shortcodes
# ==========
# Click Bold
docEditor.setCursorPosition(39)
docToolBar.tbBold.click()
assert len(docEditor.getText()) == length + 17
# Click Italic
docEditor.setCursorPosition(80)
docToolBar.tbItalic.click()
assert len(docEditor.getText()) == length + 24
# Click Strikethrough
docEditor.setCursorPosition(132)
docToolBar.tbStrike.click()
assert len(docEditor.getText()) == length + 31
# Click Underline
docEditor.setCursorPosition(163)
docToolBar.tbUnderline.click()
assert len(docEditor.getText()) == length + 38
# Click Superscript
docEditor.setCursorPosition(190)
docToolBar.tbSuperscript.click()
assert len(docEditor.getText()) == length + 49
# Click Subscript
docEditor.setCursorPosition(223)
docToolBar.tbSubscript.click()
assert len(docEditor.getText()) == length + 60
# Check Result
assert docEditor.getText() == (
"### A Scene\n\n"
"Text **bold** one\n\n"
"Text [b]bold[/b] two\n\n"
"Text _italic_ one\n\n"
"Text [i]italic[/i] two\n\n"
"Text ~~strikethrough~~ one\n\n"
"Text [s]strikethrough[/s] two\n\n"
"Text [u]underline[/u] one\n\n"
"Text [sup]superscript[/sup] one\n\n"
"Text [sub]subscript[/sub] one\n\n"
)
# Lists
# =====
docEditor.replaceText("One\n\nTwo\n\n")
# Click Unordered List
docEditor.setCursorPosition(1)
docToolBar.tbListU.click()
assert docEditor.getText() == "* One\n\nTwo\n\n"
# Click Ordered List
docEditor.setCursorPosition(9)
docToolBar.tbListO.click()
assert docEditor.getText() == "* One\n\n#. Two\n\n"
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_Insert(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test the document insert functions."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
text = f"### A Scene\n\n{ipsumText[0]}"
# Insert Text
# ===========
# No Document Handle
docEditor.replaceText(text)
docEditor._docHandle = None
docEditor.setCursorPosition(24)
docEditor.insertText("Stuff")
assert docEditor.getText() == text
docEditor._docHandle = C.hSceneDoc
# Insert String
docEditor.replaceText(text)
docEditor.setCursorPosition(24)
docEditor.insertText(", ipsumer,")
assert docEditor.getText() == text[:24] + ", ipsumer," + text[24:]
# Single Quotes
docEditor.replaceText(text)
docEditor.setCursorPosition(41)
docEditor.insertText(nwDocInsert.QUOTE_LS)
docEditor.setCursorPosition(53)
docEditor.insertText(nwDocInsert.QUOTE_RS)
assert docEditor.getText() == text.replace("consectetur", "\u2018consectetur\u2019")
# Double Quotes
docEditor.replaceText(text)
docEditor.setCursorPosition(41)
docEditor.insertText(nwDocInsert.QUOTE_LD)
docEditor.setCursorPosition(53)
docEditor.insertText(nwDocInsert.QUOTE_RD)
assert docEditor.getText() == text.replace("consectetur", "\u201cconsectetur\u201d")
# Invalid Inserts
docEditor.replaceText(text)
docEditor.insertText(nwDocInsert.NO_INSERT)
assert docEditor.getText() == text
docEditor.insertText(123) # type: ignore
assert docEditor.getText() == text
# Insert Comments
# ===============
docEditor.replaceText(text)
count = docEditor._qDocument.characterCount()
# Invalid Position
docEditor.setCursorPosition(12)
docEditor.insertText(nwDocInsert.FOOTNOTE)
assert docEditor.getText() == text
# Valid Position
docEditor.setCursorPosition(count)
docEditor.insertText(nwDocInsert.FOOTNOTE)
assert "[footnote:" in docEditor.getText()
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
# ===============
text = f"### A Scene\n\n\n{ipsumText[0]}"
docEditor.replaceText(text)
docEditor.setCursorLine(3)
# Invalid Keyword
docEditor.insertKeyWord("stuff")
assert docEditor.getText() == text
# Invalid Block
with monkeypatch.context() as mp:
mp.setattr(QTextBlock, "isValid", lambda *a, **k: False)
docEditor.insertKeyWord(nwKeyWords.POV_KEY)
assert docEditor.getText() == text
# Valid Keyword
docEditor.insertKeyWord(nwKeyWords.POV_KEY)
docEditor.insertText("Jane\n")
assert docEditor.getText() == text.replace("\n\n\n", "\n\n@pov: Jane\n\n", 1)
# Insert In-Block
docEditor.setCursorPosition(20)
docEditor.insertKeyWord(nwKeyWords.CHAR_KEY)
docEditor.insertText("John")
assert docEditor.getText() == text.replace("\n\n\n", "\n\n@pov: Jane\n@char: John\n\n", 1)
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_TextManipulation(
qtbot: QtBot,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test the text manipulation functions."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
text = "### A Scene\n\n{0}".format("\n\n".join(ipsumText))
docEditor.replaceText(text)
# Wrap Selection
# ==============
text = "### A Scene\n\n{0}".format("\n\n".join(ipsumText[0:2]))
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
# Wrap Equal
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
docEditor._wrapSelection("=")
assert docEditor.getText() == text.replace("consectetur", "=consectetur=")
# Wrap Unequal
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
docEditor._wrapSelection("=", "*")
assert docEditor.getText() == text.replace("consectetur", "=consectetur*")
# Past Paragraph
docEditor.replaceText(text)
cursor = docEditor.textCursor()
cursor.setPosition(13, QtMoveAnchor)
cursor.setPosition(1000, QtKeepAnchor)
docEditor.setTextCursor(cursor)
docEditor._wrapSelection("=")
newText = docEditor.getText()
newPara = list(filter(str.strip, newText.split("\n")))
assert newPara[1] == "=" + ipsumText[0] + "="
assert newPara[2] == ipsumText[1]
# Toggle Format
# =============
text = "### A Scene\n\n{0}".format("\n\n".join(ipsumText[0:2]))
# Block format repetition
docEditor.replaceText(text)
docEditor.setCursorPosition(39)
docEditor._toggleFormat(1, "=")
assert docEditor.getText() == text.replace("amet", "=amet=", 1)
docEditor._toggleFormat(1, "=")
assert docEditor.getText() == text.replace("amet", "=amet=", 1)
# Wrap Single Equal
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
docEditor._toggleFormat(1, "=")
assert docEditor.getText() == text.replace("consectetur", "=consectetur=")
# Past Paragraph
docEditor.replaceText(text)
cursor = docEditor.textCursor()
cursor.setPosition(13, QtMoveAnchor)
cursor.setPosition(1000, QtKeepAnchor)
docEditor.setTextCursor(cursor)
docEditor._toggleFormat(1, "=")
newText = docEditor.getText()
newPara = list(filter(str.strip, newText.split("\n")))
assert newPara[1] == "=" + ipsumText[0] + "="
assert newPara[2] == ipsumText[1]
# Wrap Double Equal
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
docEditor._toggleFormat(2, "=")
assert docEditor.getText() == text.replace("consectetur", "==consectetur==")
# Toggle Double Equal with Selection
docEditor.replaceText(text)
docEditor.setCursorSelection(41, 11)
docEditor._toggleFormat(2, "=")
assert docEditor.getText() == text.replace("consectetur", "==consectetur==")
assert docEditor.getSelectedText() == "consectetur"
docEditor._toggleFormat(2, "=")
assert docEditor.getText() == text
assert docEditor.getSelectedText() == "consectetur"
# Toggle Double Equal
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
docEditor._toggleFormat(2, "=")
docEditor._toggleFormat(2, "=")
assert docEditor.getText() == text
# Toggle Triple+Double Equal
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
docEditor._toggleFormat(3, "=")
docEditor._toggleFormat(2, "=")
assert docEditor.getText() == text.replace("consectetur", "=consectetur=")
# Toggle Unequal
repText = text.replace("consectetur", "=consectetur==")
docEditor.replaceText(repText)
docEditor.setCursorPosition(45)
docEditor._toggleFormat(1, "=")
assert docEditor.getText() == text.replace("consectetur", "consectetur=")
docEditor._toggleFormat(1, "=")
assert docEditor.getText() == repText
# Replace Quotes
# ==============
# No Selection
text = "### A Scene\n\n{0}".format(ipsumText[0].replace("consectetur", "=consectetur="))
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
docEditor._replaceQuotes("=", "<", ">")
assert docEditor.getText() == text
# First Paragraph Selected
# This should not replace anything in second paragraph
text = "### A Scene\n\n{0}".format("\n\n".join(ipsumText[0:2]).replace("ipsum", "=ipsum="))
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
assert docEditor.docAction(nwDocAction.SEL_PARA)
docEditor._replaceQuotes("=", "<", ">")
newText = docEditor.getText()
newPara = list(filter(str.strip, newText.split("\n")))
assert newPara[1] == ipsumText[0].replace("ipsum", "<ipsum>")
assert newPara[2] == ipsumText[1].replace("ipsum", "=ipsum=")
# Edge of Document
text = ipsumText[0].replace("Lorem", "=Lorem=")
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
assert docEditor.docAction(nwDocAction.SEL_ALL)
docEditor._replaceQuotes("=", "<", ">")
assert docEditor.getText() == text.replace("=Lorem=", "<Lorem>")
# Remove Line Breaks
# ==================
parOne = ipsumText[0].replace(" ", "\n", 5)
parTwo = ipsumText[1].replace(" ", "\n", 5)
# Check Blocks
cursor = docEditor.textCursor()
cursor.clearSelection()
text = f"### A Scene\n\n{parOne}\n\n{parTwo}"
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
assert len(docEditor._selectedBlocks(cursor)) == 0
cursor.select(QtSelectDocument)
assert len(docEditor._selectedBlocks(cursor)) == 15
# Remove All
text = f"### A Scene\n\n{parOne}\n\n{parTwo}"
docEditor.replaceText(text)
docEditor.setCursorPosition(45)
docEditor._removeInParLineBreaks()
assert docEditor.getText() == "### A Scene\n\n{0}\n".format("\n\n".join(ipsumText[0:2]))
# Remove in First Paragraph
# Second paragraphs should remain unchanged
text = f"### A Scene\n\n{parOne}\n\n{parTwo}"
docEditor.replaceText(text)
cursor = docEditor.textCursor()
cursor.setPosition(16, QtMoveAnchor)
cursor.setPosition(680, QtKeepAnchor)
docEditor.setTextCursor(cursor)
docEditor._removeInParLineBreaks()
newText = docEditor.getText()
newPara = list(filter(str.strip, newText.split("\n")))
twoBits = parTwo.split()
assert newPara[1] == ipsumText[0]
assert newPara[2] == twoBits[0]
assert newPara[3] == twoBits[1]
assert newPara[4] == twoBits[2]
assert newPara[5] == twoBits[3]
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}"
docEditor.replaceText(text)
assert docEditor.getText() == text
# Select All
qtbot.keyClick(docEditor, Qt.Key.Key_A, modifier=QtModCtrl, delay=KEY_DELAY)
qtbot.keyClick(docEditor, Qt.Key.Key_Delete, delay=KEY_DELAY)
assert docEditor.getText() == ""
# Undo
qtbot.keyClick(docEditor, Qt.Key.Key_Z, modifier=QtModCtrl, delay=KEY_DELAY)
assert docEditor.getText() == text
# Redo
qtbot.keyClick(docEditor, Qt.Key.Key_Y, modifier=QtModCtrl, delay=KEY_DELAY)
assert docEditor.getText() == ""
# Let the auto-scroll animation, if one was triggered by a cursor
# move above, finish before the widget tree is torn down
qtbot.wait(150)
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_BlockFormatting(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test the block formatting function."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
# Invalid and Generic
# ===================
text = f"### A Scene\n\n{ipsumText[0]}"
docEditor.replaceText(text)
# Invalid Block
docEditor.setCursorPosition(0)
with monkeypatch.context() as mp:
mp.setattr(QTextBlock, "isValid", lambda *a, **k: False)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is False
# Keyword
docEditor.replaceText("@pov: Jane\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is False
assert docEditor.getText() == "@pov: Jane\n\n"
assert docEditor.getCursorPosition() == 5
# Unsupported Format
docEditor.replaceText("% Comment\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.NO_ACTION) is False
# Block Stripping : Left Side
# ===========================
# Strip Comment w/Space
docEditor.replaceText("% Comment\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Comment\n\n"
assert docEditor.getCursorPosition() == 3
# Strip Comment wo/Space
docEditor.replaceText("%Comment\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Comment\n\n"
assert docEditor.getCursorPosition() == 4
# Strip Header 1
docEditor.replaceText("# Title\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Title\n\n"
assert docEditor.getCursorPosition() == 3
# Strip Header 2
docEditor.replaceText("## Title\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Title\n\n"
assert docEditor.getCursorPosition() == 2
# Strip Header 3
docEditor.replaceText("### Title\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Title\n\n"
assert docEditor.getCursorPosition() == 1
# Strip Header 4
docEditor.replaceText("#### Title\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Title\n\n"
assert docEditor.getCursorPosition() == 0
# Strip Novel Title
docEditor.replaceText("#! Title\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Title\n\n"
assert docEditor.getCursorPosition() == 2
# Strip Unnumbered Chapter
docEditor.replaceText("##! Title\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Title\n\n"
assert docEditor.getCursorPosition() == 1
# Strip Hard Scene
docEditor.replaceText("###! Title\n\n")
docEditor.setCursorPosition(6)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Title\n\n"
assert docEditor.getCursorPosition() == 1
# Strip Text
docEditor.replaceText("Generic text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Generic text\n\n"
assert docEditor.getCursorPosition() == 5
# Strip Left Angle Brackets : Double w/Space
docEditor.replaceText(">> Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 2
# Strip Left Angle Brackets : Single w/Space
docEditor.replaceText("> Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 3
# Strip Left Angle Brackets : Double wo/Space
docEditor.replaceText(">>Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 3
# Strip Left Angle Brackets : Single wo/Space
docEditor.replaceText(">Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 4
# Block Stripping : Right Side
# ============================
# Strip Right Angle Brackets : Double w/Space
docEditor.replaceText("Some text <<\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 5
# Strip Right Angle Brackets : Single w/Space
docEditor.replaceText("Some text <\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 5
# Strip Right Angle Brackets : Double wo/Space
docEditor.replaceText("Some text<<\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 5
# Strip Right Angle Brackets : Single wo/Space
docEditor.replaceText("Some text<\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 5
# Block Stripping : Both Sides
# ============================
docEditor.replaceText(">> Some text <<\n\n")
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
docEditor.replaceText(">Some text <<\n\n")
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
docEditor.replaceText(">Some text<\n\n")
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
# New Formats
# ===========
# Comment
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_COM) is True
assert docEditor.getText() == "% Some text\n\n"
assert docEditor.getCursorPosition() == 7
# Toggle Comment w/Space
docEditor.replaceText("% Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_COM) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 3
# Toggle Comment wo/Space
docEditor.replaceText("%Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_COM) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 4
# Toggle Ignore Text w/Space
docEditor.replaceText("%~ Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_IGN) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 2
# Toggle Ignore Text wo/Space
docEditor.replaceText("%~Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_IGN) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 3
# Header 1
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_H1) is True
assert docEditor.getText() == "# Some text\n\n"
assert docEditor.getCursorPosition() == 7
# Header 2
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_H2) is True
assert docEditor.getText() == "## Some text\n\n"
assert docEditor.getCursorPosition() == 8
# Header 3
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_H3) is True
assert docEditor.getText() == "### Some text\n\n"
assert docEditor.getCursorPosition() == 9
# Header 4
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_H4) is True
assert docEditor.getText() == "#### Some text\n\n"
assert docEditor.getCursorPosition() == 10
# Novel Title
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_TTL) is True
assert docEditor.getText() == "#! Some text\n\n"
assert docEditor.getCursorPosition() == 8
# Unnumbered Chapter
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_UNN) is True
assert docEditor.getText() == "##! Some text\n\n"
assert docEditor.getCursorPosition() == 9
# Hard Scene
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_HSC) is True
assert docEditor.getText() == "###! Some text\n\n"
assert docEditor.getCursorPosition() == 10
# Unordered List
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_LST_U) is True
assert docEditor.getText() == "* Some text\n\n"
assert docEditor.getCursorPosition() == 7
# Ordered List
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.BLOCK_LST_O) is True
assert docEditor.getText() == "#. Some text\n\n"
assert docEditor.getCursorPosition() == 8
# Unordered to Ordered List, and back
docEditor.replaceText("* Some text\n\n")
docEditor.setCursorPosition(7)
assert docEditor._formatBlock(nwDocAction.BLOCK_LST_O) is True
assert docEditor.getText() == "#. Some text\n\n"
assert docEditor.getCursorPosition() == 8
assert docEditor._formatBlock(nwDocAction.BLOCK_LST_U) is True
assert docEditor.getText() == "* Some text\n\n"
assert docEditor.getCursorPosition() == 7
# List to Plain Text
docEditor.replaceText("#. Some text\n\n")
docEditor.setCursorPosition(8)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 5
# Toggle Lists Off
docEditor.replaceText("* Some text\n\n")
docEditor.setCursorPosition(7)
assert docEditor._formatBlock(nwDocAction.BLOCK_LST_U) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 5
docEditor.replaceText("#. Some text\n\n")
docEditor.setCursorPosition(8)
assert docEditor._formatBlock(nwDocAction.BLOCK_LST_O) is True
assert docEditor.getText() == "Some text\n\n"
assert docEditor.getCursorPosition() == 5
# A separator is not a list item
docEditor.replaceText("* * *\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.ALIGN_C) is True
assert docEditor.getText() == ">> * * * <<\n\n"
# Left Indent
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.INDENT_L) is True
assert docEditor.getText() == "> Some text\n\n"
assert docEditor.getCursorPosition() == 7
# Right Indent
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.INDENT_R) is True
assert docEditor.getText() == "Some text <\n\n"
assert docEditor.getCursorPosition() == 5
# Right/Left Indent
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.INDENT_L) is True
assert docEditor._formatBlock(nwDocAction.INDENT_R) is True
assert docEditor.getText() == "> Some text <\n\n"
assert docEditor.getCursorPosition() == 7
# Left Align
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.ALIGN_L) is True
assert docEditor.getText() == "Some text <<\n\n"
assert docEditor.getCursorPosition() == 5
# Right Align
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.ALIGN_R) is True
assert docEditor.getText() == ">> Some text\n\n"
assert docEditor.getCursorPosition() == 8
# Centre Align
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.ALIGN_C) is True
assert docEditor.getText() == ">> Some text <<\n\n"
assert docEditor.getCursorPosition() == 8
# Left/Right Align (Overrides)
docEditor.replaceText("Some text\n\n")
docEditor.setCursorPosition(5)
assert docEditor._formatBlock(nwDocAction.ALIGN_L) is True
assert docEditor._formatBlock(nwDocAction.ALIGN_R) is True
assert docEditor.getText() == ">> Some text\n\n"
assert docEditor.getCursorPosition() == 8
# Other Checks
# ============
# Final Cursor Position Out of Range
docEditor.replaceText("#### Title\n\n")
docEditor.setCursorPosition(3)
assert docEditor._formatBlock(nwDocAction.BLOCK_TXT) is True
assert docEditor.getText() == "Title\n\n"
assert docEditor.getCursorPosition() == 5
# Third Line
# This also needs to add a new block
docEditor.replaceText("#### Title\n\nThe Text\n\n")
docEditor.setCursorLine(3)
assert docEditor._formatBlock(nwDocAction.BLOCK_COM) is True
assert docEditor.getText() == "#### Title\n\n% The Text\n\n"
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_MultiBlockFormatting(
qtbot: QtBot,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test the block formatting function."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
text = "### A Scene\n\n@char: Jane, John\n\n" + "\n\n".join(ipsumText) + "\n\n"
docEditor.replaceText(text)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
"Lorem",
"",
"Nulla",
"",
"Nulla",
"",
"Pelle",
"",
"Integ",
"",
]
# Toggle Comment
cursor = docEditor.textCursor()
cursor.setPosition(50)
cursor.movePosition(QtMoveRight, QtKeepAnchor, 2000)
docEditor.setTextCursor(cursor)
docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
"% Lor",
"",
"% Nul",
"",
"% Nul",
"",
"% Pel",
"",
"Integ",
"",
]
# Un-toggle the second
cursor = docEditor.textCursor()
cursor.setPosition(800)
docEditor.setTextCursor(cursor)
docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
"% Lor",
"",
"Nulla",
"",
"% Nul",
"",
"% Pel",
"",
"Integ",
"",
]
# Un-toggle all
cursor = docEditor.textCursor()
cursor.setPosition(50)
cursor.movePosition(QtMoveRight, QtKeepAnchor, 3000)
docEditor.setTextCursor(cursor)
docEditor._iterFormatBlocks(nwDocAction.BLOCK_COM)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
"Lorem",
"",
"Nulla",
"",
"Nulla",
"",
"Pelle",
"",
"Integ",
"",
]
# Toggle Ignore Text
cursor = docEditor.textCursor()
cursor.setPosition(50)
cursor.movePosition(QtMoveRight, QtKeepAnchor, 2000)
docEditor.setTextCursor(cursor)
docEditor._iterFormatBlocks(nwDocAction.BLOCK_IGN)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
"%~ Lo",
"",
"%~ Nu",
"",
"%~ Nu",
"",
"%~ Pe",
"",
"Integ",
"",
]
# Clear all paragraphs
cursor = docEditor.textCursor()
cursor.setPosition(50)
cursor.movePosition(QtMoveRight, QtKeepAnchor, 3000)
docEditor.setTextCursor(cursor)
docEditor._iterFormatBlocks(nwDocAction.BLOCK_TXT)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
"Lorem",
"",
"Nulla",
"",
"Nulla",
"",
"Pelle",
"",
"Integ",
"",
]
# Toggle List
cursor = docEditor.textCursor()
cursor.setPosition(50)
cursor.movePosition(QtMoveRight, QtKeepAnchor, 2000)
docEditor.setTextCursor(cursor)
docEditor._iterFormatBlocks(nwDocAction.BLOCK_LST_U)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
"* Lor",
"",
"* Nul",
"",
"* Nul",
"",
"* Pel",
"",
"Integ",
"",
]
# Un-toggle all, the first item decides
cursor = docEditor.textCursor()
cursor.setPosition(50)
cursor.movePosition(QtMoveRight, QtKeepAnchor, 3000)
docEditor.setTextCursor(cursor)
docEditor._iterFormatBlocks(nwDocAction.BLOCK_LST_U)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
"Lorem",
"",
"Nulla",
"",
"Nulla",
"",
"Pelle",
"",
"Integ",
"",
]
# Align across a heading and a keyword, which are skipped
cursor = docEditor.textCursor()
cursor.setPosition(0)
cursor.movePosition(QtMoveRight, QtKeepAnchor, 2000)
docEditor.setTextCursor(cursor)
docEditor.docAction(nwDocAction.ALIGN_C)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
">> Lo",
"",
">> Nu",
"",
">> Nu",
"",
">> Pe",
"",
"Integ",
"",
]
# The affected blocks are reselected
lastBlock = docEditor._qDocument.findBlockByNumber(10)
assert lastBlock.text().startswith(">> Pe")
cursor = docEditor.textCursor()
assert cursor.selectionStart() == 0
assert cursor.selectionEnd() == lastBlock.position() + lastBlock.length() - 1
# Toggle back off, the first formattable block decides
docEditor.docAction(nwDocAction.ALIGN_C)
assert [x[:5] for x in docEditor.getText().splitlines()] == [
"### A",
"",
"@char",
"",
"Lorem",
"",
"Nulla",
"",
"Nulla",
"",
"Pelle",
"",
"Integ",
"",
]
# Indent the same selection
docEditor.docAction(nwDocAction.INDENT_L)
docEditor.docAction(nwDocAction.INDENT_R)
assert docEditor.getText().splitlines()[4].startswith("> Lorem")
assert docEditor.getText().splitlines()[4].endswith(" <")
assert docEditor.getText().splitlines()[0] == "### A Scene"
cursor = docEditor.textCursor()
cursor.setPosition(50)
cursor.movePosition(QtMoveRight, QtKeepAnchor, 2000)
docEditor.setTextCursor(cursor)
docEditor.docAction(nwDocAction.BLOCK_TXT)
# A single heading is not aligned
docEditor.setCursorPosition(3)
assert docEditor._formatBlock(nwDocAction.ALIGN_C) is False
assert docEditor.getText().splitlines()[0] == "### A Scene"
# Final text should be identical to initial text
assert docEditor.getText() == text
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_Tags(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, ipsumText: list[str], mockRnd: MockRnd):
"""Test the document editor tags functionality."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
docViewer = nwGUI.docViewer
# Create Scene
text = "### A Scene\n\n@char: Jane, John\n\n@object: Gun\n\n@:\n\n" + ipsumText[0] + "\n\n"
docEditor.replaceText(text)
# Create Character
text = "### Jane Doe\n\n@tag: Jane\n\n" + ipsumText[1] + "\n\n"
cHandle = SHARED.project.newFile("Jane Doe", C.hCharRoot)
nwGUI.docPanes.openDocument(cHandle)
docEditor.replaceText(text)
nwGUI.docPanes.saveDocument()
# Follow Tag
# ==========
nwGUI.docPanes.openDocument(C.hSceneDoc)
# Empty Block
docEditor.setCursorLine(2)
assert docEditor._processTag() == _TagAction.NONE
# Not On Tag
docEditor.setCursorLine(1)
assert docEditor._processTag() == _TagAction.NONE
# On Tag Keyword
docEditor.setCursorPosition(15)
assert docEditor._processTag() == _TagAction.NONE
# On Known Tag, No Follow
docEditor.setCursorPosition(22)
assert docEditor._processTag(follow=False) == _TagAction.FOLLOW
assert docViewer._docHandle is None
# On Known Tag, Follow
docEditor.setCursorPosition(22)
position = QPointF(docEditor.cursorRect().center())
event = QMouseEvent(QEvent.Type.MouseButtonPress, position, QtMouseLeft, QtMouseLeft, QtModCtrl)
assert docViewer._docHandle is None
docEditor.mouseReleaseEvent(event)
assert docViewer._docHandle == cHandle
nwGUI.docPanes.closeViewerPanel()
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
# On Unknown Tag, Create It
nwGUI.docPanes.openDocument(C.hSceneDoc)
assert "0000000000012" not in SHARED.project.tree
docEditor.setCursorPosition(28)
assert docEditor._processTag(create=True) == _TagAction.CREATE
assert "0000000000012" in SHARED.project.tree
# On Unknown Tag, Missing Root
assert "0000000000013" not in SHARED.project.tree
docEditor.setCursorPosition(42)
assert docEditor._processTag(create=True) == _TagAction.CREATE
oHandle = SHARED.project.tree.findRoot(nwItemClass.OBJECT)
assert oHandle == "0000000000013"
oItem = SHARED.project.tree["0000000000014"]
assert oItem is not None
assert oItem.itemParent == "0000000000013"
docEditor.setCursorPosition(47)
assert docEditor._processTag() == _TagAction.NONE
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_HoverCard(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test the mouse-driven reference tag hover card: the debounce
timer is only started when the block under the cursor holds any
meta data at all, showing the card requires the more precise
position check inside _showHoverCard() to also resolve to a tag,
and moving off a tag or leaving the editor entirely schedules a
delayed hide rather than closing it outright.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
cHandle = SHARED.project.newFile("Jane Doe", C.hCharRoot)
assert nwGUI.docPanes.openDocument(cHandle) is True
docEditor.replaceText("# Jane Doe\n\n@tag: Jane\n\n")
nwGUI.docPanes.saveDocument()
nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor.replaceText("### A Scene\n\n@char: Jane\n\n")
def moveTo(position: int) -> QPoint:
docEditor.setCursorPosition(position)
point = docEditor.cursorRect().center()
event = QMouseEvent(
QEvent.Type.MouseMove, QPointF(point), Qt.MouseButton.NoButton, Qt.MouseButton.NoButton, QtModNone
)
docEditor.mouseMoveEvent(event)
return point
# Hovering over the "Jane" tag starts the debounce timer, and once
# it fires, the card resolves and shows the tag
moveTo(22)
assert docEditor._timerHover.isActive() is True
docEditor._showHoverCard()
assert docEditor._hoverCard.isVisible() is True
assert "Jane" in docEditor._hoverCard._label.text()
# A position within the same block that isn't over the tag itself
# does not resolve, so _showHoverCard() schedules a hide instead
# of showing the card, even though the block holds meta data
docEditor.setCursorPosition(15)
docEditor._hoverPos = docEditor.cursorRect().center()
docEditor._showHoverCard()
assert docEditor._hoverCard._hideTimer.isActive() is True
docEditor._hoverCard._hideTimer.stop()
# Moving onto a block with no meta data at all stops the timer and
# schedules a hide without ever re-checking the exact position
moveTo(4)
assert docEditor._timerHover.isActive() is False
assert docEditor._hoverCard._hideTimer.isActive() is True
docEditor._hoverCard._hideTimer.stop()
# Leaving the editor entirely also schedules a delayed hide, giving
# the mouse a chance to reach the card itself
moveTo(22)
docEditor.leaveEvent(QEvent(QEvent.Type.Leave))
assert docEditor._timerHover.isActive() is False
assert docEditor._hoverCard._hideTimer.isActive() is True
# A tag update prunes the corresponding hover card cache entry, by
# its lower-cased key, so a stale synopsis or title cannot be shown
# after the tag it belongs to has changed
docEditor._hoverCard._cache["jane"] = "<p>Stale</p>"
docEditor._hoverCard._tag = "jane"
docEditor.updateChangedTags(["jane"], [])
assert "jane" not in docEditor._hoverCard._cache
assert docEditor._hoverCard._tag == ""
# No updated or deleted tags is a no-op
docEditor._hoverCard._cache["jane"] = "<p>Cached</p>"
docEditor.updateChangedTags([], [])
assert docEditor._hoverCard._cache == {"jane": "<p>Cached</p>"}
# A new or rebuilt index clears the whole cache
docEditor.indexHasAppeared()
assert docEditor._hoverCard._cache == {}
@pytest.mark.gui
def testGuiDocEditor_ProcessTagEdgeCases(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
mockRnd: 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(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.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 testGuiDocEditor_MoveTextToNewDocument(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test the moving text to new document feature."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text, info: (text, True))
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor: GuiDocEditor = nwGUI.docEditor
text = "### A Scene\n\n{0}".format("\n\n".join(ipsumText))
docEditor.replaceText(text)
# Move from cursor
nHandle = "0000000000011"
docEditor.setCursorLine(6)
docEditor.docAction(nwDocAction.MOVE_TEXT)
item = SHARED.project.tree[nHandle]
assert isinstance(item, ProjectItem)
assert item.itemName == "New Scene (1)"
assert nwGUI.docPanes.openDocument(nHandle) is True
assert docEditor.getText() == "### New Scene (1)\n\n{0}\n".format("\n\n".join(ipsumText[2:]))
# New from selection, with existing header
nHandle = "0000000000012"
docEditor.setCursorLine(5)
sPos = docEditor.getCursorPosition()
docEditor.insertText("### Another Scene\n\n")
# Select new title plus next paragraph
docEditor.setCursorLine(8)
ePos = docEditor.getCursorPosition()
cursor = docEditor.textCursor()
cursor.setPosition(sPos)
cursor.movePosition(QtMoveRight, QtKeepAnchor, ePos - sPos)
docEditor.setTextCursor(cursor)
# Move to new document
docEditor.docAction(nwDocAction.MOVE_TEXT)
item = SHARED.project.tree[nHandle]
assert isinstance(item, ProjectItem)
assert item.itemName == "Another Scene"
assert nwGUI.docPanes.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.docPanes.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.docPanes.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()
@pytest.mark.gui
def testGuiDocEditor_Links(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test the document editor links functionality."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
docEditor.replaceText("### Scene\n\nFoo http://www.example.com bar.\n\n")
docEditor.setCursorPosition(20)
position = QPointF(docEditor.cursorRect().center())
event = QMouseEvent(QEvent.Type.MouseButtonPress, position, QtMouseLeft, QtMouseLeft, QtModCtrl)
with monkeypatch.context() as mp:
openUrl = MagicMock()
mp.setattr(QDesktopServices, "openUrl", openUrl)
docEditor.mouseReleaseEvent(event)
assert openUrl.called is True
assert openUrl.call_args[0][0] == QUrl("http://www.example.com")
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_FormatLink(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
mockRnd: MockRnd,
):
"""Test formatting a link under the cursor via the edit dialog."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
# Cancelling the dialog is a no-op, regardless of context
docEditor.replaceText("Foo http://example.com bar.")
docEditor.setCursorPosition(6)
with monkeypatch.context() as mp:
mp.setattr(GuiEditLink, "getLink", lambda *a, text="", url="": (text, url, False))
docEditor.docAction(nwDocAction.MD_LINK)
assert docEditor.getText() == "Foo http://example.com bar."
# A bare URL under the cursor is detected via TextBlockData and its
# URL is passed to the dialog, with no link text
captured = {}
def fakeBareUrl(*_a: Any, text: str = "", url: str = "") -> tuple[str, str, bool]:
captured["text"], captured["url"] = text, url
return "a link", url, True
with monkeypatch.context() as mp:
mp.setattr(GuiEditLink, "getLink", fakeBareUrl)
assert docEditor.docAction(nwDocAction.MD_LINK) is True
assert captured == {"text": "", "url": "http://example.com"}
assert docEditor.getText() == "Foo [a link](http://example.com) bar."
# An existing Markdown link under the cursor has both its text and
# URL passed to the dialog, and the whole construct is replaced
docEditor.replaceText("Foo [a link](http://example.com) bar.")
docEditor.setCursorPosition(10)
captured.clear()
def fakeEditLink(*_a: Any, text: str = "", url: str = "") -> tuple[str, str, bool]:
captured["text"], captured["url"] = text, url
return "new text", "http://new.example.com", True
with monkeypatch.context() as mp:
mp.setattr(GuiEditLink, "getLink", fakeEditLink)
assert docEditor.docAction(nwDocAction.MD_LINK) is True
assert captured == {"text": "a link", "url": "http://example.com"}
assert docEditor.getText() == "Foo [new text](http://new.example.com) bar."
# With no link or selection at the cursor, a new link is inserted
docEditor.replaceText("Foo bar.")
docEditor.setCursorPosition(4)
with monkeypatch.context() as mp:
mp.setattr(GuiEditLink, "getLink", lambda *a, text="", url="": ("site", "http://site.com", True))
assert docEditor.docAction(nwDocAction.MD_LINK) is True
assert docEditor.getText() == "Foo [site](http://site.com)bar."
# A selection with no identified link pre-fills the link text
docEditor.replaceText("Foo bar baz.")
cursor = docEditor.textCursor()
cursor.setPosition(4)
cursor.setPosition(7, QtKeepAnchor)
docEditor.setTextCursor(cursor)
captured.clear()
with monkeypatch.context() as mp:
mp.setattr(GuiEditLink, "getLink", fakeEditLink)
assert docEditor.docAction(nwDocAction.MD_LINK) is True
assert captured == {"text": "bar", "url": ""}
assert docEditor.getText() == "Foo [new text](http://new.example.com) baz."
# A blank URL from the dialog is a no-op
docEditor.replaceText("Foo bar.")
docEditor.setCursorPosition(4)
with monkeypatch.context() as mp:
mp.setattr(GuiEditLink, "getLink", lambda *a, text="", url="": ("text", "", True))
assert docEditor.docAction(nwDocAction.MD_LINK) is True
assert docEditor.getText() == "Foo bar."
# With more than one identified URL in the block, the one matching
# the cursor position is picked, not just the first one
docEditor.replaceText("Foo http://one.com and http://two.com bar.")
docEditor.setCursorPosition(30)
captured.clear()
with monkeypatch.context() as mp:
mp.setattr(GuiEditLink, "getLink", fakeBareUrl)
assert docEditor.docAction(nwDocAction.MD_LINK) is True
assert captured == {"text": "", "url": "http://two.com"}
# An empty block has no TextBlockData at all, which must not be
# mistaken for an identified link
docEditor.replaceText("")
docEditor.setCursorPosition(0)
with monkeypatch.context() as mp:
mp.setattr(GuiEditLink, "getLink", lambda *a, text="", url="": ("site", "http://site.com", True))
assert docEditor.docAction(nwDocAction.MD_LINK) is True
assert docEditor.getText() == "[site](http://site.com)"
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_InternalSlotEdgeCases(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test defensive branches in a few internal slots and functions
that aren't covered by their respective feature tests.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.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
CONFIG.lineHighlight = False
docEditor._cursorMoved()
# 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
# With auto-select disabled, or with an existing selection, the
# cursor is returned unchanged
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"
# 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)
cursor = docEditor._autoSelect()
assert cursor.selectedText() != ""
# Copying a selection must only put plain text on the clipboard,
# not also HTML, regardless of the widget's rich text rendering
docEditor.setPlainText("first\n\nsecond")
docEditor.docAction(nwDocAction.SEL_ALL)
mime = docEditor.createMimeDataFromSelection()
assert mime.hasHtml() is False
assert mime.text() == "first\n\nsecond"
@pytest.mark.gui
def testGuiDocEditor_UpdateDocMargins(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that the margins collapse to the viewport padding when no
fixed text width or Focus Mode is in effect.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
CONFIG.textWidth = 0
SHARED.setFocusMode(False)
docEditor.updateDocMargins()
# Tool bar and search box are placed on opposite sides
docEditor.beginSearch()
assert docEditor.docSearch.isVisible()
tB = docEditor.frameWidth()
vBar = docEditor.verticalScrollBar()
sW = vBar.width() if vBar and vBar.isVisible() else 0
CONFIG.rtlMode = False
docEditor.updateDocMargins()
assert docEditor.docToolBar.x() == 0
assert docEditor.docSearch.x() == docEditor.width() - sW - docEditor.docSearch.width() - 2 * tB
# In RTL, the tool bar position uses its size hint, also while hidden
CONFIG.rtlMode = True
docEditor.docToolBar.setVisible(False)
docEditor.updateDocMargins()
assert docEditor.docToolBar.x() == docEditor.width() - docEditor.docToolBar.sizeHint().width()
assert docEditor.docSearch.x() == 2
@pytest.mark.gui
def testGuiDocEditor_ScrollPastEnd(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test the scroll-past-end feature, which fakes QPlainTextEdit's
centerOnScroll via a bottom margin on the document's root frame.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
docEditor.resize(400, 300)
rootFrame = docEditor._qDocument.rootFrame()
assert rootFrame is not None
vBar = docEditor.verticalScrollBar()
assert vBar is not None
CONFIG.scrollPastEnd = True
docEditor.updateDocMargins()
assert rootFrame.frameFormat().bottomMargin() > 0
maxWithScrollPastEnd = vBar.maximum()
CONFIG.scrollPastEnd = False
docEditor.updateDocMargins()
assert rootFrame.frameFormat().bottomMargin() == 0
assert vBar.maximum() < maxWithScrollPastEnd
@pytest.mark.gui
def testGuiDocEditor_TypewriterScrolling(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test the typewriter scrolling (auto-scroll) feature, which
animates the scrollbar by the actual pixel movement of the cursor.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
docEditor.resize(400, 300)
docEditor.setPlainText("Text\n\n" * 100)
cursor = docEditor.textCursor()
cursor.setPosition(0)
docEditor.setTextCursor(cursor)
vBar = docEditor.verticalScrollBar()
assert vBar is not None
vBar.setValue(0)
CONFIG.autoScroll = True
CONFIG.autoScrollPos = 30
assert vBar.value() == 0
# Move the cursor down past the auto-scroll threshold by inserting
# newlines, which advances the cursor without hitting the MOVE_KEYS
# exclusion used for arrow-key navigation
for _ in range(30):
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
qtbot.wait(200) # Let the scroll animation finish
assert vBar.value() > 0
@pytest.mark.gui
def testGuiDocEditor_LineHeight(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that CONFIG.lineHeight is applied to all blocks in the
document, both on load and when settings are refreshed.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
document = docEditor._qDocument
def allBlocksHaveLineHeight(height: int) -> bool:
block = document.firstBlock()
while block.isValid():
if block.blockFormat().lineHeight() != height:
return False
block = block.next()
return True
CONFIG.lineHeight = 1.50
assert docEditor.loadText(C.hSceneDoc) is True
assert allBlocksHaveLineHeight(150)
CONFIG.lineHeight = 2.00
docEditor.initEditor()
assert allBlocksHaveLineHeight(200)
@pytest.mark.gui
def testGuiDocEditor_LineHeightPaste(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that pasting multi-line text keeps the configured line
height on every resulting block, including the ones created after
a line break in the pasted text (Issue #2874).
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
document = docEditor._qDocument
def allBlocksHaveLineHeight(height: int) -> bool:
block = document.firstBlock()
while block.isValid():
if block.blockFormat().lineHeight() != height:
return False
block = block.next()
return True
CONFIG.lineHeight = 1.50
assert docEditor.loadText(C.hSceneDoc) is True
assert allBlocksHaveLineHeight(150)
docEditor.setCursorPosition(0)
mime = QMimeData()
mime.setText("Pasted first line.\nPasted second line.\nPasted third line.")
docEditor.insertFromMimeData(mime)
assert docEditor.getText().startswith("Pasted first line.\nPasted second line.\nPasted third line.")
assert allBlocksHaveLineHeight(150)
@pytest.mark.gui
def testGuiDocEditor_InsertFromMimeData_Markdown(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that pasting Markdown mime data is converted to
novelWriter's own text format before being inserted, and that a
Markdown paste that converts to an empty string is a no-op.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
assert docEditor.loadText(C.hSceneDoc) is True
docEditor.setCursorPosition(0)
mime = QMimeData()
mime.setData(nwConst.MIME_MARKDOWN, b"# Title\n\nSome **bold** text.\n")
docEditor.insertFromMimeData(mime)
assert docEditor.getText().startswith("# Title\n\nSome **bold** text.")
text = docEditor.getText()
emptyMd = QMimeData()
emptyMd.setData(nwConst.MIME_MARKDOWN, b"")
docEditor.insertFromMimeData(emptyMd)
assert docEditor.getText() == text
# A rich text (HTML) paste with a meaningful leading space (as a
# non-breaking space, which is how rich text sources encode a
# leading space that would otherwise collapse in HTML) must only
# have the blank line added by the HTML-to-text conversion
# stripped, not the leading space itself
docEditor.replaceText("A cat.")
docEditor.setCursorPosition(1)
htmlMime = QMimeData()
htmlMime.setHtml("<p>&nbsp;big</p>")
docEditor.insertFromMimeData(htmlMime)
assert docEditor.getText().startswith("A\u00a0big cat.")
@pytest.mark.gui
def testGuiDocEditor_InsertFromMimeData_Urls(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that dropped file and web URLs are inserted as properly
encoded URLs rather than as raw, unencoded text.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
assert docEditor.loadText(C.hSceneDoc) is True
docEditor.setCursorPosition(0)
# A local file path with a space, as provided by a file manager drag,
# must be percent-encoded so that it is recognised as a single URL
mime = QMimeData()
mime.setUrls([QUrl.fromLocalFile("/some/path/my file.txt")])
docEditor.insertFromMimeData(mime)
assert docEditor.getText().startswith("file:///some/path/my%20file.txt")
# Multiple dropped files are inserted one per line
docEditor.replaceText("")
docEditor.setCursorPosition(0)
mime = QMimeData()
mime.setUrls([QUrl.fromLocalFile("/some/one.txt"), QUrl.fromLocalFile("/some/two.txt")])
docEditor.insertFromMimeData(mime)
assert docEditor.getText().startswith("file:///some/one.txt\nfile:///some/two.txt")
# A regular web URL is preserved as-is
docEditor.replaceText("")
docEditor.setCursorPosition(0)
mime = QMimeData()
mime.setUrls([QUrl("https://example.com/page")])
docEditor.insertFromMimeData(mime)
assert docEditor.getText().startswith("https://example.com/page")
@pytest.mark.gui
def testGuiDocEditor_StripUnsafeCharacters(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Control characters must never survive an import/replace or a
paste, and must never be returned by getText or getSelectedText,
even if one reaches the document by another route. See #3038.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
assert docEditor.loadText(C.hSceneDoc) is True
# Import/replace strips control characters
docEditor.replaceText("Some\x00text\x01with control chars\n")
assert docEditor.getText() == "Sometextwith control chars\n"
# Paste strips control characters
docEditor.setCursorPosition(0)
mime = QMimeData()
mime.setText("Pasted\x00text")
docEditor.insertFromMimeData(mime)
assert docEditor.getText().startswith("Pastedtext")
# getText and getSelectedText strip control characters even if one
# reaches the document directly, bypassing the paste/import guards
cursor = docEditor.textCursor()
cursor.setPosition(0)
cursor.insertText("Raw\x00control")
docEditor.setTextCursor(cursor)
assert docEditor.getText().startswith("Rawcontrol")
cursor.setPosition(0)
cursor.movePosition(QtMoveRight, QtKeepAnchor, len("Raw\x00control"))
docEditor.setTextCursor(cursor)
assert docEditor.getSelectedText() == "Rawcontrol"
@pytest.mark.gui
def testGuiDocEditor_PasteAsPlainText(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that the Ctrl+Shift+V shortcut always inserts the
clipboard's plain text, ignoring any HTML or Markdown formatting
also present on the clipboard.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
assert docEditor.loadText(C.hSceneDoc) is True
docEditor.setCursorPosition(0)
clipboard = QApplication.clipboard()
assert clipboard is not None
clipboard.clear()
mime = QMimeData()
mime.setHtml("<p><b>Bold</b> text.</p>")
mime.setText("Plain text.")
clipboard.setMimeData(mime)
docEditor._pasteAsPlainText()
assert docEditor.getText().startswith("Plain text.")
@pytest.mark.gui
def testGuiDocEditor_LineHeightDoubleReturn(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that a blank-line paragraph break (two consecutive Return
presses) works normally with a non-default line height set on
every block. Qt's own Return handling otherwise treats the second
Return, landing on an empty block whose format isn't bare default,
as a request to reset the block back to default formatting instead
of inserting a new one (meant for escaping list/heading formatting
in rich-text editors), silently eating the keypress.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
CONFIG.lineHeight = 1.50
assert docEditor.loadText(C.hSceneDoc) is True
qtbot.keyClicks(docEditor, "# Heading")
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "Body text.")
assert docEditor.getText().startswith("# Heading\n\nBody text.")
@pytest.mark.gui
def testGuiDocEditor_ListMarkerOnReturn(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test that list markers are carried over to new lines."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
docEditor = nwGUI.docEditor
assert docEditor.loadText(C.hSceneDoc) is True
# Unordered item, then ordered item, at end of line
docEditor.setPlainText("* One")
docEditor.setCursorPosition(5)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "Two")
assert docEditor.getText() == "* One\n* Two"
docEditor.setPlainText("#. One")
docEditor.setCursorPosition(6)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
qtbot.keyClicks(docEditor, "Two")
assert docEditor.getText() == "#. One\n#. Two"
# Splitting an item carries the marker
docEditor.setPlainText("* OneTwo")
docEditor.setCursorPosition(5)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
assert docEditor.getText() == "* One\n* Two"
# Return inside the marker inserts a plain line
docEditor.setPlainText("#. One")
docEditor.setCursorPosition(1)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
assert docEditor.getText() == "#\n. One"
# Return on an empty item clears the marker
docEditor.setPlainText("* One\n* ")
docEditor.setCursorPosition(8)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
assert docEditor.getText() == "* One\n"
assert docEditor.getCursorPosition() == 6
docEditor.setPlainText("#. ")
docEditor.setCursorPosition(4)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
assert docEditor.getText() == ""
# Not list items
docEditor.setPlainText("* * *")
docEditor.setCursorPosition(5)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
assert docEditor.getText() == "* * *\n"
docEditor.setPlainText("*")
docEditor.setCursorPosition(1)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
assert docEditor.getText() == "*\n"
# A selection is replaced, and the marker is from its first line
docEditor.setPlainText("* One\nText")
cursor = docEditor.textCursor()
cursor.setPosition(3)
cursor.setPosition(8, QtKeepAnchor)
docEditor.setTextCursor(cursor)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
assert docEditor.getText() == "* O\n* xt"
# A selection on an empty item is not cleared as an empty item
docEditor.setPlainText("* ")
cursor = docEditor.textCursor()
cursor.setPosition(1)
cursor.setPosition(2, QtKeepAnchor)
docEditor.setTextCursor(cursor)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
assert docEditor.getText() == "*\n"
# The marker is undone with the new line
docEditor.setPlainText("* One")
docEditor.setCursorPosition(5)
qtbot.keyClick(docEditor, QtKeyReturn, delay=KEY_DELAY)
assert docEditor.getText() == "* One\n* "
docEditor.docAction(nwDocAction.UNDO)
assert docEditor.getText() == "* One"
@pytest.mark.gui
def testGuiDocEditor_CursorVisibility(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
mockRnd: MockRnd,
):
"""Test the custom ensure cursor visible feature."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
nwGUI.docPanes.openDocument(C.hSceneDoc)
docEditor = nwGUI.docEditor
docEditor.setPlainText("### Scene\n\n" + "".join(["Text\n\n"] * 100))
assert docEditor.cursorIsVisible() is True
vBar = docEditor.verticalScrollBar()
viewport = docEditor.viewport()
assert vBar is not None
assert viewport is not None
# Scroll Down
cursor = docEditor.textCursor()
cursor.setPosition(605)
docEditor.setTextCursor(cursor)
vBar.setValue(0)
assert vBar.value() == 0
docEditor.ensureCursorVisible(centre=False)
assert vBar.value() > 0
assert docEditor.cursorIsVisible() is True
# Scroll Up
cursor = docEditor.textCursor()
cursor.setPosition(0)
docEditor.setTextCursor(cursor)
vBar.setValue(200)
assert vBar.value() > 100
docEditor.ensureCursorVisible(centre=False)
assert vBar.value() < 100
assert docEditor.cursorIsVisible() is True
# Centre Cursor
cursor = docEditor.textCursor()
cursor.setPosition(300)
docEditor.setTextCursor(cursor)
vBar.setValue(0)
docEditor.ensureCursorVisible(centre=True)
cRect = docEditor.cursorRect()
cCentre = (cRect.top() + cRect.bottom()) // 2
assert abs(cCentre - viewport.height() // 2) <= 1
assert docEditor.cursorIsVisible() is True
# Centre Cursor, Already Visible
# Even if the cursor is already visible but off-centre, centre=True
# should still move it, rather than no-op like centre=False does
vBarAfterCentre = vBar.value()
vBar.setValue(vBarAfterCentre - 5)
assert docEditor.cursorIsVisible() is True
docEditor.ensureCursorVisible(centre=True)
assert vBar.value() == vBarAfterCentre
cRect = docEditor.cursorRect()
cCentre = (cRect.top() + cRect.bottom()) // 2
assert abs(cCentre - viewport.height() // 2) <= 1
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_ReplaceNextEdgeCases(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
mockRnd: 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(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.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")
# 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
# 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 testGuiDocEditor_WordCounters(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test the word counter."""
docEditor = nwGUI.docEditor
class MockThreadPool:
def __init__(self):
self._runObj = None
def start(self, runObj: QRunnable, priority: int = 0) -> None:
self._runObj = runObj
def runnable(self):
return self._runObj
def waitForDone(self, msecs: int = -1) -> bool:
return True
def clear(self):
pass
threadPool = MockThreadPool()
monkeypatch.setattr(QThreadPool, "globalInstance", lambda *a: threadPool)
docEditor._timerDoc.blockSignals(True)
docEditor._timerSel.blockSignals(True)
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
nwGUI.docPanes.closeDocument() # Opening the project auto-restores a document
# Run on an empty document
docEditor._runDocumentTasks()
assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
docEditor._updateDocCounts(0, 0, 0)
assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
docEditor._runSelCounter()
assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
docEditor._updateSelCounts(0, 0, 0)
assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
# Open a document and populate it
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
SHARED.project.tree[C.hSceneDoc]._wordInit = 0 # type: ignore
SHARED.project.tree[C.hSceneDoc]._charInit = 0 # type: ignore
SHARED.project.tree[C.hSceneDoc]._wordCount = 0 # type: ignore
docEditor.docFooter.updateMainCount(0, False)
text = "\n\n".join(ipsumText)
cC, wC, pC = standardCounter(text)
docEditor.replaceText(text)
# Check that a busy counter is blocked
with monkeypatch.context() as mp:
mp.setattr(docEditor._docCounter, "_busy", True)
docEditor._runDocumentTasks()
assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
with monkeypatch.context() as mp:
mp.setattr(docEditor._selCounter, "_busy", True)
docEditor.docAction(nwDocAction.SEL_ALL)
docEditor._runSelCounter()
assert docEditor.docFooter.wordsText.text() == "Words: 0 (+0)"
docEditor.setCursorPosition(0)
# Opening the document already dispatched its own initial word count,
# which must complete before the real dispatch below can proceed
runnable = threadPool.runnable()
assert runnable is not None
runnable.run()
assert docEditor._docCounter.busy is False
# Run the full word counter
docEditor._lastEdit = time() # Simulate an edit since the last run
docEditor._runDocumentTasks()
assert docEditor._docCounter.busy is True
# The dispatcher also guards against re-dispatch on its own, not
# just via the busy check in the calling code
dispatched = threadPool.runnable()
docEditor._docCounter.count("some other text")
assert threadPool.runnable() is dispatched
runnable = threadPool.runnable()
assert runnable is not None
runnable.run()
assert docEditor._docCounter.busy is False
assert SHARED.project.tree[C.hSceneDoc]._charCount == cC # type: ignore
assert SHARED.project.tree[C.hSceneDoc]._wordCount == wC # type: ignore
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()
assert docEditor._selCounter.busy is True
runnable = threadPool.runnable()
assert runnable is not None
runnable.run()
assert docEditor._selCounter.busy is False
assert docEditor.docFooter.wordsText.text() == f"Selected: {wC}"
# A repeated run with no edit since the last one is skipped entirely
threadPool._runObj = None
docEditor._lastEdit = time() - 100.0
docEditor._runDocumentTasks()
assert threadPool.runnable() is None
# A genuine new edit makes the next run go ahead again
docEditor._lastEdit = time()
docEditor._runDocumentTasks()
assert threadPool.runnable() is not None
assert docEditor._timerDoc.isSingleShot() is True
assert docEditor._timerSel.isSingleShot() is True
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_Search(
qtbot: QtBot,
monkeypatch: pytest.MonkeyPatch,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test the document editor search functionality."""
monkeypatch.setattr(GuiDocEditor, "hasFocus", lambda *a: True)
def matchesIn(source: str, pattern: str, case: bool = False, word: bool = False, regex: bool = False):
"""Compute the search hits the same way the editor's own find
logic would, so expected results follow from the text itself
rather than from hardcoded positions.
"""
pat = pattern if regex else re.escape(pattern)
if word and not regex:
pat = rf"\b{pat}\b"
flags = 0 if case else re.IGNORECASE
return [(m.start(), m.end()) for m in re.finditer(pat, source, flags)]
def setToggle(button: QAbstractButton, state: bool) -> None:
"""Click a checkable tool button until it reaches the wanted state."""
if button.isChecked() != state:
button.click()
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
# Add a couple of extra scenes to search across for the "next file" tests
secondHandle = SHARED.project.newFile("Second Scene", C.hChapterDir)
thirdHandle = SHARED.project.newFile("Third Scene", C.hChapterDir)
fourthHandle = SHARED.project.newFile("Fourth Scene", C.hChapterDir)
assert secondHandle
assert thirdHandle
assert fourthHandle
secondText = "### Second Scene\n\nFirst kumquat here. Second kumquat there.\n"
thirdText = "### Third Scene\n\nNothing special here.\n"
fourthText = "### Fourth Scene\n\nStill nothing special.\n"
for handle, content in ((secondHandle, secondText), (thirdHandle, thirdText), (fourthHandle, fourthText)):
doc = SHARED.project.storage.getDocument(handle)
doc.writeDocument(content)
SHARED.project.index.reIndexHandle(handle)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor: GuiDocEditor = nwGUI.docEditor
docSearch: GuiDocEditSearch = docEditor.docSearch
text = "### A Scene\n\n{0}".format("\n\n".join(ipsumText[:3]))
docEditor.replaceText(text)
origText = docEditor.getText()
def findAll(pattern: str, case: bool = False, word: bool = False, regex: bool = False):
return matchesIn(docEditor.getText(), pattern, case, word, regex)
# Select the word "est"
anyMatches = findAll("est")
wordMatches = findAll("est", word=True)
assert len(wordMatches) < len(anyMatches) # Whole word search is more restrictive
wStart, wEnd = wordMatches[1]
docEditor.setCursorPosition(wStart + 1)
docEditor._makeSelection(QtSelectWord)
cursor = docEditor.textCursor()
assert cursor.selectedText() == "est"
# Activate search
nwGUI.mainMenu.aFind.activate(QAction.ActionEvent.Trigger)
assert docSearch.isVisible()
assert docSearch.searchText == "est"
idx = anyMatches.index((wStart, wEnd))
# Find next by enter key
monkeypatch.setattr(docSearch.searchBox, "hasFocus", lambda: True)
qtbot.keyClick(docSearch.searchBox, QtKeyReturn, delay=KEY_DELAY)
idx += 1
assert docEditor.getCursorPosition() == anyMatches[idx][1]
# Find next by button
qtbot.mouseClick(docSearch.searchButton, QtMouseLeft, delay=KEY_DELAY)
idx += 1
assert docEditor.getCursorPosition() == anyMatches[idx][1]
# Activate loop search
setToggle(docSearch.tbLoop, True)
assert CONFIG.searchLoop is True
# Find next by menu Search > Find Next until it wraps back to the first hit
for _ in range(len(anyMatches) - idx):
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.getCursorPosition() == anyMatches[0][1]
# Typing next to a highlighted match should not extend it
mStart, mEnd = anyMatches[0]
match = origText[mStart:mEnd]
highlight = docEditor._searchSelections[0].cursor
assert highlight.selectedText() == match
cursor = docEditor.textCursor()
cursor.setPosition(mEnd)
cursor.insertText("xx")
assert highlight.selectedText() == match
cursor.setPosition(mStart)
cursor.insertText("yy")
assert highlight.selectedText() == match
cursor.setPosition(mStart)
cursor.setPosition(mEnd + 4, QtKeepAnchor)
cursor.insertText(match)
assert docEditor.getText() == origText
# Close search
docSearch.tbCancel.click()
assert docSearch.isVisible() is False
docEditor.setCursorPosition(0)
# Toggle search again with header button
qtbot.mouseClick(docEditor.docHeader.searchButton, QtMouseLeft, delay=KEY_DELAY)
docSearch.setSearchText("")
assert docSearch.isVisible() is True
# Search for non-existing
docEditor.setCursorPosition(0)
docSearch.setSearchText("abcdef")
qtbot.mouseClick(docSearch.searchButton, QtMouseLeft, delay=KEY_DELAY)
assert docEditor.getCursorPosition() == 0 # No result
# Enable RegEx search
setToggle(docSearch.tbRegEx, True)
assert CONFIG.searchRegEx is True
# Set invalid RegEx
docEditor.setCursorPosition(0)
docSearch.setSearchText(r"\bSus[")
qtbot.mouseClick(docSearch.searchButton, QtMouseLeft, delay=KEY_DELAY)
assert docEditor.getCursorPosition() == 0 # No result
# Set dangerous RegEx (issue #1015)
# If this doesn't get caught, the app will hang
docEditor.setCursorPosition(0)
docSearch.setSearchText(r".*")
qtbot.mouseClick(docSearch.searchButton, QtMouseLeft, delay=KEY_DELAY)
assert docEditor.getCursorPosition() == docEditor.getText().index("\n") # Matches the heading line only
# Set valid RegEx
susMatches = findAll(r"\bSus", regex=True)
docSearch.setSearchText(r"\bSus")
qtbot.mouseClick(docSearch.searchButton, QtMouseLeft, delay=KEY_DELAY)
assert docEditor.getCursorPosition() == susMatches[0][1]
# Find next and then prev
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.getCursorPosition() == susMatches[1][1]
nwGUI.mainMenu.aFindPrev.activate(QAction.ActionEvent.Trigger)
assert docEditor.getCursorPosition() == susMatches[0][1]
# Make RegEx case sensitive
setToggle(docSearch.tbCase, True)
assert CONFIG.searchCase is True
# Find next/prev (one result, since only "Suspendisse" is capitalised)
susCaseMatches = findAll(r"\bSus", regex=True, case=True)
assert len(susCaseMatches) == 1
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.getCursorPosition() == susCaseMatches[0][1]
nwGUI.mainMenu.aFindPrev.activate(QAction.ActionEvent.Trigger)
assert docEditor.getCursorPosition() == susCaseMatches[0][1]
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.getCursorPosition() == susCaseMatches[0][1]
# Trigger replace. This resets the search box to the current selection, so
# the search text must be restored afterwards.
nwGUI.mainMenu.aReplace.activate(QAction.ActionEvent.Trigger)
docSearch.setSearchText(r"\bSus")
docSearch.setReplaceText("foo")
# Disable RegEx case sensitive
setToggle(docSearch.tbCase, False)
assert CONFIG.searchCase is False
# Toggle replace preserve case
setToggle(docSearch.tbMatchCap, True)
assert CONFIG.searchMatchCap is True
# Replace "Sus" with "Foo" via menu, landing on the capitalised "Suspendisse"
susCiMatches = findAll(r"\bSus", regex=True)
assert len(susCiMatches) == 3
docEditor.setCursorPosition(susCiMatches[1][1])
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
nwGUI.mainMenu.aReplaceNext.activate(QAction.ActionEvent.Trigger)
replacedAt = susCiMatches[2][0]
assert docEditor.getText()[replacedAt : replacedAt + 11] == "Foopendisse"
# The replaced hit no longer counts as a match
susRemaining = findAll(r"\bSus", regex=True)
assert len(susRemaining) == len(susCiMatches) - 1
# Find next/prev still works after the replace
posBefore = docEditor.getCursorPosition()
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
posAfter = docEditor.getCursorPosition()
assert posAfter in [e for _, e in susRemaining]
assert posAfter != posBefore
nwGUI.mainMenu.aFindPrev.activate(QAction.ActionEvent.Trigger)
assert docEditor.getCursorPosition() == posBefore
# Replace "sus" with "foo" via replace button
assert docEditor.textCursor().selectedText() == "sus"
qtbot.mouseClick(docSearch.replaceButton, QtMouseLeft, delay=KEY_DELAY)
susAfterButton = findAll(r"\bSus", regex=True)
assert len(susAfterButton) == len(susRemaining) - 1
# Revert last two replaces
assert docEditor.docAction(nwDocAction.UNDO)
assert docEditor.docAction(nwDocAction.UNDO)
assert docEditor.getText() == origText
# Disable RegEx search
setToggle(docSearch.tbRegEx, False)
assert CONFIG.searchRegEx is False
# Close search and select "est" again
docSearch.tbCancel.click()
wordMatches = findAll("est", word=True)
wStart, wEnd = wordMatches[0]
docEditor.setCursorPosition(wStart + 1)
docEditor._makeSelection(QtSelectWord)
cursor = docEditor.textCursor()
assert cursor.selectedText() == "est"
# Activate search again
nwGUI.mainMenu.aFind.activate(QAction.ActionEvent.Trigger)
assert docSearch.isVisible()
assert docSearch.searchText == "est"
# Enable full word search
setToggle(docSearch.tbWord, True)
assert CONFIG.searchWord is True
# Word search steps through the whole-word hits only
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.getCursorPosition() == wordMatches[1][1]
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.getCursorPosition() == wordMatches[2][1]
with monkeypatch.context() as mp:
mp.setattr(docEditor, "_docHandle", None)
docEditor.setCursorPosition(len(docEditor.getText()))
docEditor.findNext()
assert docEditor.getCursorPosition() == wordMatches[0][1]
docEditor.setCursorPosition(0)
docEditor.findNext(goBack=True)
assert docEditor.getCursorPosition() == wordMatches[-1][1]
cursorPos = docEditor.getCursorPosition()
docEditor.openNextFindDocument(docEditor, False)
assert docEditor.getCursorPosition() == cursorPos
# Enable next doc search
setToggle(docSearch.tbProject, True)
assert CONFIG.searchNextFile is True
# Next match, jumping to the next document that has a hit
secondMatches = matchesIn(secondText, "kumquat", word=True)
docSearch.setSearchText("kumquat")
docEditor.setCursorPosition(0)
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.docHandle == secondHandle
assert docEditor.getCursorPosition() == secondMatches[0][1]
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.docHandle == secondHandle
assert docEditor.getCursorPosition() == secondMatches[1][1]
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.docHandle == thirdHandle # No hits here, so just moves on
nwGUI.mainMenu.aFindPrev.activate(QAction.ActionEvent.Trigger)
assert docEditor.docHandle == secondHandle
assert docEditor.getCursorPosition() == secondMatches[-1][1]
# Next doc, no match anywhere, so it just keeps advancing one document at a time
docSearch.setSearchText("abcdef")
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.docHandle == thirdHandle
nwGUI.mainMenu.aFindNext.activate(QAction.ActionEvent.Trigger)
assert docEditor.docHandle == fourthHandle
# Toggle Replace
docEditor.beginReplace()
# MonkeyPatch the focus cycle. We can't really test this very well, other than
# check that the tabs aren't captured when the main editor has focus
with monkeypatch.context() as mp:
mp.setattr(docEditor, "hasFocus", lambda: True)
mp.setattr(docSearch.searchBox, "hasFocus", lambda: False)
mp.setattr(docSearch.replaceBox, "hasFocus", lambda: False)
assert docEditor.focusNextPrevChild(True) is False
assert docSearch.cycleFocus() is False
with monkeypatch.context() as mp:
mp.setattr(docEditor, "hasFocus", lambda: False)
mp.setattr(docSearch.searchBox, "hasFocus", lambda: True)
mp.setattr(docSearch.replaceBox, "hasFocus", lambda: False)
assert docEditor.focusNextPrevChild(True) is True
assert docSearch.cycleFocus() is True
with monkeypatch.context() as mp:
mp.setattr(docEditor, "hasFocus", lambda: False)
mp.setattr(docSearch.searchBox, "hasFocus", lambda: False)
mp.setattr(docSearch.replaceBox, "hasFocus", lambda: True)
assert docEditor.focusNextPrevChild(True) is True
assert docSearch.cycleFocus() is True
docSearch.closeSearch()
assert docSearch.isVisible() is False
assert docEditor.focusNextPrevChild(True) is True
# While the completer popup is open, Tab must stay with the editor
# even if hasFocus() is momentarily wrong, which has been observed
# to happen on macOS right after the popup, a separate top-level
# window, is shown
with monkeypatch.context() as mp:
mp.setattr(docEditor, "hasFocus", lambda: False)
popup = docEditor._completer.popup()
assert popup is not None
mp.setattr(popup, "isVisible", lambda: True)
assert docEditor.focusNextPrevChild(True) is False
# Replace Text
# ============
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
setToggle(docSearch.tbCase, True)
setToggle(docSearch.tbWord, False)
setToggle(docSearch.tbRegEx, False)
setToggle(docSearch.tbLoop, False)
setToggle(docSearch.tbProject, False)
docEditor.setCursorPosition(0)
# Replace Next
docSearch.searchBox.setText("a")
docSearch.replaceBox.setText("A")
aMatches = findAll("a", case=True)
# No focus
with monkeypatch.context() as mp:
mp.setattr(docEditor, "anyFocus", lambda: False)
docEditor.findNext()
assert docEditor.textCursor().selectedText() == ""
docEditor.replaceNext()
assert docEditor.textCursor().selectedText() == ""
# Search not open
docSearch.closeSearch()
assert docSearch.isVisible() is False
docEditor.findNext()
assert docSearch.isVisible() is True
docSearch.closeSearch()
assert docSearch.isVisible() is False
docEditor.replaceNext()
assert docSearch.isVisible() is True
docEditor.toggleSearch()
assert docSearch.isVisible() is False
docEditor.toggleSearch()
assert docSearch.isVisible() is True
# Find first entry
docEditor.replaceNext()
assert docEditor.textCursor().selectedText() == "a"
assert docEditor.getCursorPosition() == aMatches[0][1]
# Treat the search as a user selection. Since "A" preserves the case of the
# matched (lowercase) text, the replacement is a no-op character-wise, but it
# still counts as a real replace-and-advance, landing on the next hit.
docEditor._lastFind = None
docEditor.replaceNext()
assert docEditor.textCursor().selectedText() == "a"
assert docEditor.getCursorPosition() == aMatches[1][1]
# Iterate through the rest, each call replacing the current hit and
# advancing to the next one
lastIdx = 13
for i in range(2, lastIdx + 1):
docEditor.replaceNext()
assert docEditor.textCursor().selectedText() == "a"
assert docEditor.getCursorPosition() == aMatches[i][1]
assert docEditor._lastFind == aMatches[lastIdx]
# Search for something that doesn't exist
docSearch.searchBox.setText("abcdef")
docEditor._lastFind = None
docEditor.replaceNext()
assert docEditor.textCursor().selectedText() == ""
# qtbot.stop()
@pytest.mark.gui
def testGuiDocEditor_SearchAutoReplace(qtbot: QtBot, nwGUI: GuiMain, projPath: Path, mockRnd: MockRnd):
"""Test the auto-replace symbols feature of the search and replace boxes."""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
docSearch: GuiDocEditSearch = docEditor.docSearch
docEditor.toggleSearch()
assert docSearch.isVisible()
# Auto-replace is off by default, so typed text is left untouched
assert CONFIG.searchAuto is False
qtbot.keyClicks(docSearch.searchBox, "Test...", delay=KEY_DELAY)
assert docSearch.searchText == "Test..."
qtbot.keyClicks(docSearch.replaceBox, "Test...", delay=KEY_DELAY)
assert docSearch.replaceText == "Test..."
docSearch.searchBox.clear()
docSearch.replaceBox.clear()
# Turn auto-replace on, and check that it now converts the text as it is typed
docSearch.tbAuto.click()
assert CONFIG.searchAuto is True
qtbot.keyClicks(docSearch.searchBox, "Test...", delay=KEY_DELAY)
assert docSearch.searchText == f"Test{nwUnicode.U_HELLIP}"
qtbot.keyClicks(docSearch.replaceBox, "Test...", delay=KEY_DELAY)
assert docSearch.replaceText == f"Test{nwUnicode.U_HELLIP}"
docSearch.tbAuto.click()
assert CONFIG.searchAuto is False
@pytest.mark.gui
def testGuiDocEditor_BigFixes(qtbot: QtBot, nwGUI: GuiMain):
"""Test specific bug fixes in the editor."""
docEditor = nwGUI.docEditor
# Cursor rectangle should take into account viewport margins (Issues #2267 and #2517)
vM = docEditor.viewportMargins()
cR = docEditor.inputMethodQuery(QtImCursorRectangle)
assert isinstance(cR, QRect)
assert cR.left() >= vM.left()
assert cR.top() >= vM.top()
# This one should simply not crash, and return a plain QVariant (Issue #2622)
cV = docEditor.inputMethodQuery(QtImCurrentSelection)
assert isinstance(cV, QVariant)
@pytest.mark.gui
def testGuiDocEditor_BigDocLifecycle(
qtbot: QtBot,
nwGUI: GuiMain,
projPath: Path,
ipsumText: list[str],
mockRnd: MockRnd,
):
"""Test that loading, navigating, and refreshing a large document
queues cursor moves and keeps the editor read-only until settled.
"""
buildTestProject(NWProject(), projPath)
nwGUI.openProject(projPath)
assert nwGUI.docPanes.openDocument(C.hSceneDoc) is True
docEditor = nwGUI.docEditor
unit = "\n\n".join(ipsumText)
reps = nwConst.BIG_DOC_LIMIT // len(unit) + 2
text = "### Big Doc\n\n" + "\n\n".join([unit] * reps)
docEditor.replaceText(text)
nwGUI.docPanes.saveDocument()
nwGUI.docPanes.closeDocument()
cursPos = len(text) - 500
item = SHARED.project.tree[C.hSceneDoc]
assert item is not None
item.setCursorPos(cursPos)
# Loading queues the saved cursor position and stays read-only
assert docEditor.loadText(C.hSceneDoc) is True
assert docEditor._qDocument.isLayoutBusy() is True
assert docEditor.isReadOnly() is True
assert docEditor._queuePos == cursPos
assert docEditor.getCursorPosition() != cursPos
# A spell check pass is also skipped while busy
docEditor._checkPassPos = 99
docEditor.spellCheckDocument()
assert docEditor._checkPassPos == 99
with qtbot.waitSignal(docEditor._qDocument.layoutSettled, timeout=5000):
pass
assert docEditor._qDocument.isLayoutBusy() is False
assert docEditor.isReadOnly() is False
assert docEditor._queuePos is None
assert docEditor.getCursorPosition() == cursPos
# Moving to a line in the still-large, now settled document queues again
targetLine = docEditor._qDocument.blockCount()
assert targetLine > 1
docEditor.setCursorLine(targetLine)
assert docEditor._qDocument.isLayoutBusy() is True
assert docEditor._queuePos is not None
with qtbot.waitSignal(docEditor._qDocument.layoutSettled, timeout=5000):
pass
assert docEditor._queuePos is None
block = docEditor._qDocument.findBlockByNumber(targetLine - 1)
assert docEditor.getCursorPosition() == block.position()
# Refreshing the editor, e.g. on a settings change, re-marks it busy too
docEditor.initEditor()
assert docEditor._qDocument.isLayoutBusy() is True
assert docEditor.isReadOnly() is True
with qtbot.waitSignal(docEditor._qDocument.layoutSettled, timeout=5000):
pass
assert docEditor._qDocument.isLayoutBusy() is False
assert docEditor.isReadOnly() is False