From eb0fac8a43d491747f9dc0a52eafc65e1f2f02f5 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Tue, 26 Mar 2024 00:26:24 +0100 Subject: [PATCH] Improve test coverage --- novelwriter/gui/search.py | 25 +++-- tests/test_core/test_core_coretools.py | 97 ++++++++++++++++++- tests/test_gui/test_gui_search.py | 125 +++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 15 deletions(-) create mode 100644 tests/test_gui/test_gui_search.py diff --git a/novelwriter/gui/search.py b/novelwriter/gui/search.py index e4e1be38..27ef4b4b 100644 --- a/novelwriter/gui/search.py +++ b/novelwriter/gui/search.py @@ -28,7 +28,7 @@ import logging from time import time from PyQt5.QtCore import QSize, Qt, pyqtSignal, pyqtSlot -from PyQt5.QtGui import QKeyEvent, QPalette +from PyQt5.QtGui import QCursor, QKeyEvent, QPalette from PyQt5.QtWidgets import ( QHBoxLayout, QHeaderView, QLabel, QLineEdit, QToolBar, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, qApp @@ -110,7 +110,6 @@ class GuiProjectSearch(QWidget): self.searchResult.setColumnCount(2) self.searchResult.setIconSize(QSize(iPx, iPx)) self.searchResult.setIndentation(iPx) - self.searchResult.itemPressed.connect(self._searchResultPressed) self.searchResult.itemDoubleClicked.connect(self._searchResultDoubleClicked) self.searchResult.itemSelectionChanged.connect(self._searchResultSelected) @@ -160,8 +159,15 @@ class GuiProjectSearch(QWidget): """Process a return keypress forwarded from the main GUI.""" if self.searchText.hasFocus(): self._processSearch() - elif items := self.searchResult.selectedItems(): - self._searchResultPressed(items[0], 0) + elif ( + self.searchResult.hasFocus() + and (items := self.searchResult.selectedItems()) + and (data := items[0].data(0, self.D_RESULT)) + and len(data) == 3 + ): + self.openDocumentSelectRequest.emit( + str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), False + ) return def beginSearch(self) -> None: @@ -211,6 +217,7 @@ class GuiProjectSearch(QWidget): def _processSearch(self) -> None: """Perform a search.""" if not self._blocked: + qApp.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) start = time() self._blocked = True self.searchResult.clear() @@ -222,6 +229,7 @@ class GuiProjectSearch(QWidget): self._appendResultSet(item, results, capped) logger.debug("Search took %.3f ms", 1000*(time() - start)) self._time = time() + qApp.restoreOverrideCursor() self._blocked = False return @@ -235,15 +243,6 @@ class GuiProjectSearch(QWidget): self.selectedItemChanged.emit(str(data)) return - @pyqtSlot("QTreeWidgetItem*", int) - def _searchResultPressed(self, item: QTreeWidgetItem, column: int) -> None: - """Process search result pressed.""" - if (data := item.data(0, self.D_RESULT)) and len(data) == 3: - self.openDocumentSelectRequest.emit( - str(data[0]), checkInt(data[1], -1), checkInt(data[2], -1), False - ) - return - @pyqtSlot("QTreeWidgetItem*", int) def _searchResultDoubleClicked(self, item: QTreeWidgetItem, column: int) -> None: """Process search result double click.""" diff --git a/tests/test_core/test_core_coretools.py b/tests/test_core/test_core_coretools.py index 85ebedfd..1e205156 100644 --- a/tests/test_core/test_core_coretools.py +++ b/tests/test_core/test_core_coretools.py @@ -32,9 +32,11 @@ from tools import C, NWD_IGNORE, buildTestProject, cmpFiles, XML_IGNORE from mocked import causeOSError from novelwriter import CONFIG -from novelwriter.constants import nwFiles, nwItemClass +from novelwriter.constants import nwConst, nwFiles, nwItemClass +from novelwriter.core.coretools import ( + DocDuplicator, DocMerger, DocSearch, DocSplitter, ProjectBuilder +) from novelwriter.core.project import NWProject -from novelwriter.core.coretools import DocDuplicator, DocMerger, DocSplitter, ProjectBuilder @pytest.mark.core @@ -401,6 +403,97 @@ def testCoreTools_DocDuplicator(mockGUI, fncPath, tstPaths, mockRnd): # END Test testCoreTools_DocDuplicator +@pytest.mark.core +def testCoreTools_DocSearch(monkeypatch, mockGUI, fncPath, mockRnd, ipsumText): + """Test the DocDuplicator utility.""" + project = NWProject() + mockRnd.reset() + buildTestProject(project, fncPath) + project.storage.getDocument(C.hSceneDoc).writeDocument( + "### New Scene\n\n" + "\n\n".join(ipsumText) + ) + + search = DocSearch() + + # Defaults + # ======== + + result = [(i.itemHandle, r, c) for i, r, c in search.iterSearch(project, "Scene")] + assert result[0] == (C.hTitlePage, [], False) + assert result[1] == (C.hChapterDoc, [], False) + assert result[2] == (C.hSceneDoc, [(8, 5, "Scene")], False) + + # Cache + assert list(search._cache.keys()) == [C.hTitlePage, C.hChapterDoc, C.hSceneDoc] + + # Patterns + # ======== + + # Escape Using QRegularExpression + with monkeypatch.context() as mp: + mp.setattr(CONFIG, "verQtValue", 0x050f00) + assert search._buildPattern("[A-Za-z0-9_]+") == r"\[A\-Za\-z0\-9_\]\+" + + # Escape Using Custom Implementation + with monkeypatch.context() as mp: + mp.setattr(CONFIG, "verQtValue", 0x050d00) + assert search._buildPattern("[A-Za-z0-9_]+") == r"\[A\-Za\-z0\-9_\]\+" + + # Whole Words + search.setWholeWords(True) + search.setUserRegEx(True) + assert search._buildPattern("Hi") == r"\bHi\b" + assert search._buildPattern(r"\bHi") == r"\bHi\b" + assert search._buildPattern(r"Hi\b") == r"\bHi\b" + assert search._buildPattern(r"\bHi\b") == r"\bHi\b" + search.setWholeWords(False) + search.setUserRegEx(False) + + # Test Settings + # ============= + + def pruneResult(result, index): + temp = [(i.itemHandle, r, c) for i, r, c in result][index][1] + return [(s, n, c.split()[0]) for s, n, c in temp] + + # Defaults + assert pruneResult(search.iterSearch(project, "Lorem"), 2) == [ + (15, 5, "Lorem"), (754, 5, "lorem"), (2056, 5, "lorem,"), (2209, 5, "lorem"), + (2425, 5, "lorem"), (2840, 5, "lorem."), (3399, 5, "lorem"), + ] + + # Whole Words + search.setWholeWords(True) + assert pruneResult(search.iterSearch(project, "Lor"), 2) == [] + search.setWholeWords(False) + assert pruneResult(search.iterSearch(project, "Lor"), 2) == [ + (15, 3, "Lorem"), (29, 3, "lor"), (754, 3, "lorem"), (2056, 3, "lorem,"), + (2209, 3, "lorem"), (2425, 3, "lorem"), (2840, 3, "lorem."), (3328, 3, "lor."), + (3399, 3, "lorem"), + ] + + # As RegEx + search.setWholeWords(False) + search.setUserRegEx(True) + assert pruneResult(search.iterSearch(project, r"Lor\b"), 2) == [ + (29, 3, "lor"), (3328, 3, "lor."), + ] + + # Max Results + with monkeypatch.context() as mp: + mp.setattr(nwConst, "MAX_SEARCH_RESULT", 3) + assert pruneResult(search.iterSearch(project, "Lorem"), 2) == [ + (15, 5, "Lorem"), (754, 5, "lorem"), (2056, 5, "lorem,"), + ] + + # Case Sensitive + search.setCaseSensitive(True) + assert pruneResult(search.iterSearch(project, "Lorem"), 2) == [(15, 5, "Lorem")] + search.setCaseSensitive(False) + +# END Test testCoreTools_DocSearch + + @pytest.mark.core def testCoreTools_ProjectBuilderWrapper(monkeypatch, caplog, fncPath, mockGUI): """Test the wrapper function of the project builder.""" diff --git a/tests/test_gui/test_gui_search.py b/tests/test_gui/test_gui_search.py new file mode 100644 index 00000000..20a18b9c --- /dev/null +++ b/tests/test_gui/test_gui_search.py @@ -0,0 +1,125 @@ +""" +novelWriter – Main GUI Project Search Tester +============================================ + +This file is a part of novelWriter +Copyright 2018–2024, Veronica Berglyd Olsen + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, but +WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . +""" +from __future__ import annotations + +import pytest + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QAction + +from novelwriter.enum import nwView +from novelwriter.gui.search import GuiProjectSearch + + +@pytest.mark.gui +def testGuiDocSearch_Main(qtbot, monkeypatch, nwGUI, prjLipsum): + """Test navigating the novel tree.""" + nwGUI.openProject(prjLipsum) + nwGUI._changeView(nwView.SEARCH) + search = nwGUI.projSearch + + def totalCount(): + nonlocal search + res = search.searchResult + return sum( + int(res.topLevelItem(i).text(GuiProjectSearch.C_COUNT).strip("()")) + for i in range(res.topLevelItemCount()) + ) + + # Plain search + search.searchText.setText("Lorem") + search.searchAction.activate(QAction.ActionEvent.Trigger) + assert search.searchResult.topLevelItemCount() == 14 + assert totalCount() == 42 + + firstDoc = search.searchResult.topLevelItem(0) + firstResult = firstDoc.child(0) + assert firstDoc is not None + handle = firstDoc.data(GuiProjectSearch.C_RESULT, GuiProjectSearch.D_HANDLE) + result = firstResult.data(GuiProjectSearch.C_RESULT, GuiProjectSearch.D_RESULT) + assert result == (handle, 3, 5) + + # Move down + qtbot.keyClick(search, Qt.Key.Key_Down) + assert firstDoc.isSelected() is True + + # Move up + qtbot.keyClick(search, Qt.Key.Key_Up) + assert firstDoc.isSelected() is False + + # Move right does nothing + qtbot.keyClick(search, Qt.Key.Key_Right) + assert firstDoc.isSelected() is False + + # Selecting updates details + firstDoc.setSelected(True) + assert nwGUI.itemDetails._handle == handle + + # Press return + search.searchResult.setFocus() + search.searchResult.clearSelection() + firstResult.setSelected(True) + with monkeypatch.context() as mp: + mp.setattr(search.searchResult, "hasFocus", lambda *a: True) + with qtbot.waitSignal(search.openDocumentSelectRequest, timeout=1000) as signal: + qtbot.keyClick(search, Qt.Key.Key_Return) + assert signal.args == [handle, 3, 5, False] + + assert nwGUI.docEditor.docHandle == handle + assert nwGUI.docEditor.textCursor().selectedText() == "Lorem" + + # Double-click + with qtbot.waitSignal(search.openDocumentSelectRequest, timeout=1000) as signal: + search._searchResultDoubleClicked(firstResult, 0) + assert signal.args == [handle, 3, 5, True] + + # Case Sensitive + search.toggleCase.setChecked(True) + search.searchAction.activate(QAction.ActionEvent.Trigger) + assert search.searchResult.topLevelItemCount() == 7 + assert totalCount() == 17 + search.toggleCase.setChecked(False) + + # Whole Words + search.searchText.setText("dolor") + with monkeypatch.context() as mp: + mp.setattr(search.searchText, "hasFocus", lambda *a: True) + qtbot.keyClick(search, Qt.Key.Key_Return) + + assert search.searchResult.topLevelItemCount() == 10 + assert totalCount() == 34 + + search.toggleWord.setChecked(True) + search.searchAction.activate(QAction.ActionEvent.Trigger) + assert search.searchResult.topLevelItemCount() == 10 + assert totalCount() == 33 + + # RegEx + search.toggleRegEx.setChecked(True) + search.searchText.setText("(dolor|dolorem)") + search.searchAction.activate(QAction.ActionEvent.Trigger) + assert search.searchResult.topLevelItemCount() == 10 + assert totalCount() == 34 + + # qtbot.stop() + nwGUI.closeProject() + +# END Test testGuiDocSearch_Main