Add novel view test coverage

This commit is contained in:
Veronica Berglyd Olsen
2025-03-30 22:28:37 +02:00
parent 1090a160de
commit 95bef4c6c2
5 changed files with 116 additions and 147 deletions
+10
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import logging import logging
from PyQt6.QtCore import pyqtSignal, pyqtSlot from PyQt6.QtCore import pyqtSignal, pyqtSlot
from PyQt6.QtGui import QPalette
from PyQt6.QtWidgets import QComboBox, QWidget from PyQt6.QtWidgets import QComboBox, QWidget
from novelwriter import SHARED from novelwriter import SHARED
@@ -46,6 +47,7 @@ class NovelSelector(QComboBox):
self._includeAll = False self._includeAll = False
self._listFormat = None self._listFormat = None
self.currentIndexChanged.connect(self._indexChanged) self.currentIndexChanged.connect(self._indexChanged)
self.updateTheme()
return return
## ##
@@ -86,6 +88,14 @@ class NovelSelector(QComboBox):
self._listFormat = value self._listFormat = value
return return
def updateTheme(self) -> None:
"""Update theme colours."""
palette = self.palette()
palette.setBrush(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, palette.text())
self.setPalette(palette)
self.refreshNovelList()
return
## ##
# Public Slots # Public Slots
## ##
-1
View File
@@ -115,7 +115,6 @@ class GuiDocEditor(QPlainTextEdit):
editedStatusChanged = pyqtSignal(bool) editedStatusChanged = pyqtSignal(bool)
itemHandleChanged = pyqtSignal(str) itemHandleChanged = pyqtSignal(str)
loadDocumentTagRequest = pyqtSignal(str, Enum) loadDocumentTagRequest = pyqtSignal(str, Enum)
novelStructureChanged = pyqtSignal()
openDocumentRequest = pyqtSignal(str, Enum, str, bool) openDocumentRequest = pyqtSignal(str, Enum, str, bool)
requestNewNoteCreation = pyqtSignal(str, nwItemClass) requestNewNoteCreation = pyqtSignal(str, nwItemClass)
requestNextDocument = pyqtSignal(str, bool) requestNextDocument = pyqtSignal(str, bool)
+33 -21
View File
@@ -3,9 +3,11 @@ novelWriter GUI Novel Tree
============================ ============================
File History: File History:
Created: 2020-12-20 [1.1rc1] GuiNovelTree Created: 2020-12-20 [1.1rc1] GuiNovelTree
Created: 2022-06-12 [2.0rc1] GuiNovelView Created: 2022-06-12 [2.0rc1] GuiNovelView
Created: 2022-06-12 [2.0rc1] GuiNovelToolBar Created: 2022-06-12 [2.0rc1] GuiNovelToolBar
Rewritten: 2025-02-22 [2.7b1] GuiNovelView
Rewritten: 2025-02-22 [2.7b1] GuiNovelToolBar
This file is a part of novelWriter This file is a part of novelWriter
Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors Copyright (C) 2020 Veronica Berglyd Olsen and novelWriter contributors
@@ -40,7 +42,7 @@ from novelwriter import CONFIG, SHARED
from novelwriter.common import minmax, qtAddAction, qtAddMenu, qtLambda from novelwriter.common import minmax, qtAddAction, qtAddMenu, qtLambda
from novelwriter.constants import nwKeyWords, nwLabels, trConst from novelwriter.constants import nwKeyWords, nwLabels, trConst
from novelwriter.core.novelmodel import NovelModel from novelwriter.core.novelmodel import NovelModel
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwNovelExtra, nwOutline from novelwriter.enum import nwChange, nwDocMode, nwNovelExtra, nwOutline
from novelwriter.extensions.modified import NIconToolButton, NTreeView from novelwriter.extensions.modified import NIconToolButton, NTreeView
from novelwriter.extensions.novelselector import NovelSelector from novelwriter.extensions.novelselector import NovelSelector
from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON from novelwriter.gui.theme import STYLES_MIN_TOOLBUTTON
@@ -105,9 +107,6 @@ class GuiNovelView(QWidget):
def openProjectTasks(self) -> None: def openProjectTasks(self) -> None:
"""Run open project tasks.""" """Run open project tasks."""
lastNovel = SHARED.project.data.getLastHandle("novel") lastNovel = SHARED.project.data.getLastHandle("novel")
if lastNovel and lastNovel not in SHARED.project.tree:
lastNovel = SHARED.project.tree.findRoot(nwItemClass.NOVEL)
logger.debug("Setting novel tree to root item '%s'", lastNovel) logger.debug("Setting novel tree to root item '%s'", lastNovel)
lastCol = SHARED.project.options.getEnum( lastCol = SHARED.project.options.getEnum(
@@ -129,13 +128,17 @@ class GuiNovelView(QWidget):
def closeProjectTasks(self) -> None: def closeProjectTasks(self) -> None:
"""Run closing project tasks.""" """Run closing project tasks."""
logger.debug("Saving State: GuiNovelView")
lastColType = self.novelTree.lastColType lastColType = self.novelTree.lastColType
lastColSize = self.novelTree.lastColSize lastColSize = self.novelTree.lastColSize
logger.debug("Saving State: GuiNovelView")
options = SHARED.project.options options = SHARED.project.options
options.setValue("GuiNovelView", "lastCol", lastColType) options.setValue("GuiNovelView", "lastCol", lastColType)
options.setValue("GuiNovelView", "lastColSize", lastColSize) options.setValue("GuiNovelView", "lastColSize", lastColSize)
self.clearNovelView() self.clearNovelView()
return return
def setTreeFocus(self) -> None: def setTreeFocus(self) -> None:
@@ -154,9 +157,7 @@ class GuiNovelView(QWidget):
@pyqtSlot(str) @pyqtSlot(str)
def setCurrentNovel(self, rootHandle: str | None) -> None: def setCurrentNovel(self, rootHandle: str | None) -> None:
"""Set the current novel to display.""" """Set the current novel to display."""
if rootHandle and (model := SHARED.project.index.getNovelModel(rootHandle)): self.novelTree.setNovelModel(rootHandle)
self.novelTree.setModel(model)
self.novelTree.resizeColumns()
return return
@pyqtSlot(str) @pyqtSlot(str)
@@ -270,7 +271,7 @@ class GuiNovelToolBar(QWidget):
"QComboBox {border-style: none; padding-left: 0;} " "QComboBox {border-style: none; padding-left: 0;} "
"QComboBox::drop-down {border-style: none}" "QComboBox::drop-down {border-style: none}"
) )
self.novelValue.refreshNovelList() self.novelValue.updateTheme()
self.tbNovel.setVisible(self.novelValue.count() > 1) self.tbNovel.setVisible(self.novelValue.count() > 1)
self._forceRefreshNovelTree() self._forceRefreshNovelTree()
@@ -286,12 +287,13 @@ class GuiNovelToolBar(QWidget):
def buildNovelRootMenu(self) -> None: def buildNovelRootMenu(self) -> None:
"""Build the novel root menu.""" """Build the novel root menu."""
self.novelValue.refreshNovelList() self.novelValue.refreshNovelList()
self.novelView.setCurrentNovel(self.novelValue.handle)
self.tbNovel.setVisible(self.novelValue.count() > 1) self.tbNovel.setVisible(self.novelValue.count() > 1)
return return
def setCurrentRoot(self, rootHandle: str | None) -> None: def setCurrentRoot(self, rootHandle: str | None) -> None:
"""Set the current active root handle.""" """Set the current active root handle."""
if rootHandle is None: if rootHandle is None or rootHandle not in SHARED.project.tree:
rootHandle = self.novelValue.firstHandle rootHandle = self.novelValue.firstHandle
self.novelValue.setHandle(rootHandle) self.novelValue.setHandle(rootHandle)
SHARED.project.data.setLastHandle(rootHandle, "novel") SHARED.project.data.setLastHandle(rootHandle, "novel")
@@ -328,6 +330,7 @@ class GuiNovelToolBar(QWidget):
def _forceRefreshNovelTree(self) -> None: def _forceRefreshNovelTree(self) -> None:
"""Rebuild the current tree.""" """Rebuild the current tree."""
if tHandle := self.novelValue.handle: if tHandle := self.novelValue.handle:
self.novelView.setCurrentNovel(tHandle)
SHARED.project.index.refreshNovelModel(tHandle) SHARED.project.index.refreshNovelModel(tHandle)
self._refresh[tHandle] = False self._refresh[tHandle] = False
return return
@@ -441,8 +444,7 @@ class GuiNovelTree(NTreeView):
"""Get the currently selected or active handle. If multiple """Get the currently selected or active handle. If multiple
items are selected, return the first. items are selected, return the first.
""" """
if model := self._getModel(): if (model := self._getModel()) and (index := self.currentIndex()).isValid():
index = self.currentIndex()
return model.handle(index), model.key(index) return model.handle(index), model.key(index)
return None, None return None, None
@@ -450,6 +452,16 @@ class GuiNovelTree(NTreeView):
# Setters # Setters
## ##
def setNovelModel(self, tHandle: str | None) -> None:
"""Set the current novel model."""
if tHandle and (model := SHARED.project.index.getNovelModel(tHandle)):
if model is not self.model():
self.setModel(model)
self.resizeColumns()
else:
self.clearContent()
return
def setActiveHandle(self, tHandle: str | None) -> None: def setActiveHandle(self, tHandle: str | None) -> None:
"""Set the handle to be highlighted.""" """Set the handle to be highlighted."""
self._actHandle = tHandle self._actHandle = tHandle
@@ -560,18 +572,18 @@ class GuiNovelTree(NTreeView):
def _popMetaBox(self, qPos: QPoint, tHandle: str, sTitle: str) -> None: def _popMetaBox(self, qPos: QPoint, tHandle: str, sTitle: str) -> None:
"""Show the novel meta data box.""" """Show the novel meta data box."""
def appendTags(refs: dict, key: str, lines: list[str]) -> None:
"""Generate a reference list for a given reference key."""
if tags := ", ".join(refs.get(key, [])):
lines.append(f"<b>{trConst(nwLabels.KEY_NAME[key])}:</b> {tags}")
return
if head := SHARED.project.index.getItemHeading(tHandle, sTitle): if head := SHARED.project.index.getItemHeading(tHandle, sTitle):
logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle) logger.debug("Generating meta data tooltip for '%s:%s'", tHandle, sTitle)
if synopsis := head.synopsis: if synopsis := head.synopsis:
label = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]) label = trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP])
synopsis = f"<p><b>{label}:</b> {synopsis}</p>" synopsis = f"<p><b>{label}:</b> {synopsis}</p>"
def appendTags(refs: dict, key: str, lines: list[str]) -> None:
"""Generate a reference list for a given reference key."""
if tags := ", ".join(refs.get(key, [])):
lines.append(f"<b>{trConst(nwLabels.KEY_NAME[key])}:</b> {tags}")
return
lines = [] lines = []
if head := SHARED.project.index.getItemHeading(tHandle, sTitle): if head := SHARED.project.index.getItemHeading(tHandle, sTitle):
tags = head.getReferences() tags = head.getReferences()
+72 -125
View File
@@ -24,22 +24,19 @@ from pathlib import Path
import pytest import pytest
from PyQt6.QtCore import QEvent, QPoint, Qt from PyQt6.QtCore import QModelIndex, QPoint, Qt
from PyQt6.QtGui import QFocusEvent
from PyQt6.QtWidgets import QInputDialog, QToolTip from PyQt6.QtWidgets import QInputDialog, QToolTip
from novelwriter import CONFIG, SHARED from novelwriter import CONFIG, SHARED
from novelwriter.core.novelmodel import NovelModel
from novelwriter.dialogs.editlabel import GuiEditLabel from novelwriter.dialogs.editlabel import GuiEditLabel
from novelwriter.enum import nwFocus, nwItemType, nwNovelExtra from novelwriter.enum import nwFocus, nwItemType, nwNovelExtra, nwView
from novelwriter.gui.noveltree import GuiNovelTree
from novelwriter.types import QtMouseLeft, QtMouseMiddle
from tests.tools import C, buildTestProject from tests.tools import C, buildTestProject
@pytest.mark.gui @pytest.mark.gui
@pytest.mark.skip def testGuiNovelView_Content(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test navigating the novel tree.""" """Test navigating the novel tree."""
monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True)) monkeypatch.setattr(GuiEditLabel, "getLabel", lambda *a, text: (text, True))
@@ -52,20 +49,22 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
contentPath = SHARED.project.storage.contentPath contentPath = SHARED.project.storage.contentPath
assert isinstance(contentPath, Path) assert isinstance(contentPath, Path)
cHandle = "0000000000010"
(contentPath / "0000000000010.nwd").write_text( (contentPath / f"{cHandle}.nwd").write_text(
"# Jane Doe\n\n@tag: Jane\n\n", encoding="utf-8" "# Jane Doe\n\n@tag: Jane\n\n", encoding="utf-8"
) )
(contentPath / "000000000000f.nwd").write_text(( (contentPath / f"{C.hSceneDoc}.nwd").write_text((
"### Scene One\n\n" "### Scene One\n\n"
"@pov: Jane\n" "@pov: Jane\n"
"@focus: Jane\n\n" "@focus: Jane\n\n"
"% Synopsis: This is a scene." "% Synopsis: This is a scene.\n\n"
"This is some text in the edited scene."
), encoding="utf-8") ), encoding="utf-8")
novelView = nwGUI.novelView novelView = nwGUI.novelView
novelTree = novelView.novelTree novelTree = nwGUI.novelView.novelTree
novelBar = novelView.novelBar novelBar = nwGUI.novelView.novelBar
# Show/Hide Scrollbars # Show/Hide Scrollbars
# ==================== # ====================
@@ -84,153 +83,101 @@ def testGuiNovelTree_TreeItems(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
# Populate Tree # Populate Tree
# ============= # =============
root = QModelIndex()
novelView.setTreeFocus() novelView.setTreeFocus()
nwGUI._changeView(nwView.NOVEL)
nwGUI.projStack.setCurrentWidget(nwGUI.novelView) # Clear tree
nwGUI.rebuildIndex() novelView.setCurrentNovel(None)
novelTree._populateTree(rootHandle=None) assert novelTree._getModel() is None
assert novelTree.topLevelItemCount() == 3
# Rebuild should preserve selection # Reload
topItem = novelTree.topLevelItem(0) novelBar._forceRefreshNovelTree()
assert not topItem.isSelected() model = novelTree._getModel()
topItem.setSelected(True) assert isinstance(model, NovelModel)
assert novelTree.selectedItems()[0] == topItem
assert novelView.getSelectedHandle() == (C.hTitlePage, "T0001")
# Refresh using the slot for the button # Check the items
novelBar._refreshNovelTree() assert model.rowCount(root) == 3
assert novelTree.topLevelItem(0).isSelected() assert model.columnCount(root) == 3
assert model.data(model.createIndex(2, 1), Qt.ItemDataRole.DisplayRole) == "2" # Word Count
nwGUI.rebuildIndex() # This should update the word count to the edited scene
assert model.data(model.createIndex(2, 1), Qt.ItemDataRole.DisplayRole) == "10" # Word Count
# Extra Column
# ============
novelBar.setLastColType(nwNovelExtra.POV)
assert model.rowCount(root) == 3
assert model.columnCount(root) == 4
# Scene column should contain the POV character
assert model.data(model.createIndex(2, 2), Qt.ItemDataRole.DisplayRole) == "Jane"
# Resize the last column
assert novelTree.lastColSize == 25
with monkeypatch.context() as mp:
mp.setattr(QInputDialog, "getInt", lambda *a, **k: (40, True))
novelBar._selectLastColumnSize()
assert novelTree.lastColSize == 40
# Open Items # Open Items
# ========== # ==========
# Clear selection # Clear selection
novelTree.clearSelection() novelTree.clearSelection()
scItem = novelTree.topLevelItem(2) assert novelView.getSelectedHandle() == (None, None)
scItem.setSelected(True)
assert scItem.isSelected()
# Clear selection with mouse # Select scene
vPort = novelTree.viewport() novelTree.setCurrentIndex(model.createIndex(2, 0))
qtbot.mouseClick(vPort, QtMouseLeft, pos=vPort.rect().center(), delay=10) assert novelView.getSelectedHandle() == (C.hSceneDoc, "T0001")
assert not scItem.isSelected()
# Double-click item # Double-click item
scItem.setSelected(True) novelTree._onDoubleClick(model.createIndex(2, 0))
assert scItem.isSelected()
assert nwGUI.docEditor.docHandle is None
novelTree._treeDoubleClick(scItem, 0)
assert nwGUI.docEditor.docHandle == C.hSceneDoc assert nwGUI.docEditor.docHandle == C.hSceneDoc
# Open item with middle mouse button # Middle-click item
scItem.setSelected(True) novelTree._onMiddleClick(model.createIndex(2, 0))
assert scItem.isSelected()
assert nwGUI.docViewer.docHandle is None
qtbot.mouseClick(vPort, QtMouseMiddle, pos=vPort.rect().center(), delay=10)
assert nwGUI.docViewer.docHandle is None
scRect = novelTree.visualItemRect(scItem)
oldData = scItem.data(novelTree.C_TITLE, novelTree.D_HANDLE)
scItem.setData(novelTree.C_TITLE, novelTree.D_HANDLE, None)
qtbot.mouseClick(vPort, QtMouseMiddle, pos=scRect.center(), delay=10)
assert nwGUI.docViewer.docHandle is None
scItem.setData(novelTree.C_TITLE, novelTree.D_HANDLE, oldData)
qtbot.mouseClick(vPort, QtMouseMiddle, pos=scRect.center(), delay=10)
assert nwGUI.docViewer.docHandle == C.hSceneDoc assert nwGUI.docViewer.docHandle == C.hSceneDoc
# Last Column
# ===========
novelBar.setLastColType(nwNovelExtra.HIDDEN)
assert novelTree.isColumnHidden(novelTree.C_EXTRA) is True
assert novelTree.lastColType == nwNovelExtra.HIDDEN
assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == ("", "")
novelBar.setLastColType(nwNovelExtra.PLOT)
assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False
assert novelTree.lastColType == nwNovelExtra.PLOT
assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == (
"", ""
)
novelBar.setLastColType(nwNovelExtra.FOCUS)
assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False
assert novelTree.lastColType == nwNovelExtra.FOCUS
assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == (
"Jane", "Focus: Jane"
)
novelBar.setLastColType(nwNovelExtra.POV)
assert novelTree.isColumnHidden(novelTree.C_EXTRA) is False
assert novelTree.lastColType == nwNovelExtra.POV
assert novelTree._getLastColumnText(C.hSceneDoc, "T0001") == (
"Jane", "Point of View: Jane"
)
novelTree._lastCol = None
assert novelTree._getLastColumnText("0000000000000", "T0000") == ("", "")
# This forces the resizeEvent function to process labels
spSize = nwGUI.splitMain.sizes()
nwGUI.splitMain.setSizes([spSize[0] + 10, spSize[1] - 10])
# Resize the last column
with monkeypatch.context() as mp:
mp.setattr(QInputDialog, "getInt", lambda *a, **k: (40, True))
novelBar._selectLastColumnSize()
# Item Meta # Item Meta
# ========= # =========
ttText = "" toolTip = ""
def showText(pos, text): def showText(pos, text):
nonlocal ttText nonlocal toolTip
ttText = text toolTip = text
mIndex = novelTree.model().index(2, novelTree.C_MORE)
with monkeypatch.context() as mp: with monkeypatch.context() as mp:
mp.setattr(QToolTip, "showText", showText) mp.setattr(QToolTip, "showText", showText)
ttText = "" toolTip = ""
novelTree._treeItemClicked(mIndex) novelTree._onSingleClick(model.createIndex(2, 3))
assert ttText == ( assert toolTip == (
"<p><b>Point of View</b>: Jane<br><b>Focus</b>: Jane</p>" "<p><b>Point of View:</b> Jane<br><b>Focus:</b> Jane</p>"
"<p><b>Synopsis</b>: This is a scene.</p>" "<p><b>Synopsis:</b> This is a scene.</p>"
) )
ttText = "" toolTip = ""
novelTree._popMetaBox(QPoint(1, 1), C.hInvalid, "T0001") novelTree._popMetaBox(QPoint(1, 1), C.hInvalid, "T0001")
assert ttText == "" assert toolTip == ""
# Set Default Root # Active Status
# ================ # =============
SHARED.project.data.setLastHandle(C.hInvalid, "novel") assert novelBar._refresh == {C.hNovelRoot: False}
novelView.openProjectTasks()
assert novelBar.novelValue.handle == C.hNovelRoot
# Tree Focus # Add a document while tree in focus
# ========== nwGUI._changeView(nwView.PROJECT)
with monkeypatch.context() as mp: assert novelBar._active is False
mp.setattr(GuiNovelTree, "hasFocus", lambda *a: False) nwGUI.projView.projTree.setSelectedHandle(C.hChapterDir)
assert novelView.treeHasFocus() is False nwGUI.projView.projTree.newTreeItem(nwItemType.FILE, hLevel=3)
mp.setattr(GuiNovelTree, "hasFocus", lambda *a: True) assert novelBar._refresh == {C.hNovelRoot: True}
assert novelView.treeHasFocus() is True
# Other Checks # Switch back and check that the refresh status is reset
# ============ nwGUI._changeView(nwView.NOVEL)
assert novelBar._refresh == {C.hNovelRoot: False}
scItem = novelTree.topLevelItem(2)
scItem.setSelected(True)
assert scItem.isSelected()
novelTree.focusOutEvent(QFocusEvent(QEvent.Type.None_, Qt.FocusReason.MouseFocusReason))
assert not scItem.isSelected()
# Close # Close
# =====
# qtbot.stop() # qtbot.stop()
nwGUI.closeProject() nwGUI.closeProject()
+1
View File
@@ -216,6 +216,7 @@ def buildTestProject(obj: object, projPath: Path) -> None:
project.setProjectChanged(True) project.setProjectChanged(True)
project.saveProject(autoSave=True) project.saveProject(autoSave=True)
project._valid = True project._valid = True
project._tree._ready = True
if nwGUI is not None: if nwGUI is not None:
nwGUI.projView.openProjectTasks() nwGUI.projView.openProjectTasks()