diff --git a/novelwriter/core/index.py b/novelwriter/core/index.py index 775f39c4..f3178faf 100644 --- a/novelwriter/core/index.py +++ b/novelwriter/core/index.py @@ -818,13 +818,13 @@ class TagsIndex: } return - def tagName(self, tagKey: str) -> str: + def tagName(self, tagKey: str, default: str = "") -> str: """Get the name of a given tag.""" - return self._tags.get(tagKey.lower(), {}).get("name", "") + return self._tags.get(tagKey.lower(), {}).get("name", default) - def tagDisplay(self, tagKey: str) -> str: + def tagDisplay(self, tagKey: str, default: str = "") -> str: """Get the display name of a given tag.""" - return self._tags.get(tagKey.lower(), {}).get("display", "") + return self._tags.get(tagKey.lower(), {}).get("display", default) def tagHandle(self, tagKey: str) -> str | None: """Get the handle of a given tag.""" diff --git a/novelwriter/core/novelmodel.py b/novelwriter/core/novelmodel.py index 5f0601c6..b55fa615 100644 --- a/novelwriter/core/novelmodel.py +++ b/novelwriter/core/novelmodel.py @@ -32,7 +32,6 @@ from novelwriter import SHARED from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst from novelwriter.core.indexdata import IndexHeading, IndexNode from novelwriter.enum import nwNovelExtra -from novelwriter.error import logException from novelwriter.types import QtAlignRight logger = logging.getLogger(__name__) @@ -169,38 +168,28 @@ class NovelModel(QAbstractTableModel): first = current[0] last = current[-1] - if len(current) != last - first + 1: - logger.warning("Novel model entries for '%s' are not continuous", handle) - return False - remains = [] - try: - for key, head in node.items(): - if key != "T0000": - if current: - j = current.pop(0) - self._rows[j] = self._generateEntry(handle, key, head) - else: - remains.append((key, head)) + for key, head in node.items(): + if key != "T0000": + if current: + j = current.pop(0) + self._rows[j] = self._generateEntry(handle, key, head) + else: + remains.append((key, head)) - self.dataChanged.emit(self.createIndex(first, 0), self.createIndex(last, cols)) + self.dataChanged.emit(self.createIndex(first, 0), self.createIndex(last, cols)) - if remains: - self.beginInsertRows(QModelIndex(), last, last + len(remains) - 1) - for k, (key, head) in enumerate(remains, last + 1): - self._rows.insert(k, self._generateEntry(handle, key, head)) - self.endInsertRows() - elif current: - self.beginRemoveRows(QModelIndex(), current[0], current[-1]) - del self._rows[current[0]:current[-1] + 1] - self.endRemoveRows() - - except Exception: - # This is faster than to check for index boundaries. - # We definitely don't want to cause a crash. - logger.error("Novel model refresh error for '%s'", handle) - logException() - return False + if remains: + # Inserting is safe for out of bounds indices + self.beginInsertRows(QModelIndex(), last, last + len(remains) - 1) + for k, (key, head) in enumerate(remains, last + 1): + self._rows.insert(k, self._generateEntry(handle, key, head)) + self.endInsertRows() + elif current: + # Deleting ranges are safe for out of bounds indices + self.beginRemoveRows(QModelIndex(), current[0], current[-1]) + del self._rows[current[0]:current[-1] + 1] + self.endRemoveRows() return True diff --git a/tests/test_core/test_core_indexdata.py b/tests/test_core/test_core_indexdata.py index 5767a669..c4fbb3f9 100644 --- a/tests/test_core/test_core_indexdata.py +++ b/tests/test_core/test_core_indexdata.py @@ -22,6 +22,7 @@ from __future__ import annotations import pytest +from novelwriter import CONFIG from novelwriter.core.index import TagsIndex from novelwriter.core.indexdata import IndexHeading, IndexNode from novelwriter.core.item import NWItem @@ -179,6 +180,7 @@ def testCoreIndexData_IndexHeading(): assert head.charCount == 0 assert head.wordCount == 0 assert head.paraCount == 0 + assert head.mainCount == 0 assert head.synopsis == "" assert head.tag == "" assert head.references == {} @@ -205,6 +207,11 @@ def testCoreIndexData_IndexHeading(): assert head.wordCount == 4 assert head.paraCount == 2 + # Check Main Count + assert head.mainCount == 4 + CONFIG.useCharCount = True + assert head.mainCount == 42 + # Set Summary head.setSynopsis("In the beginning ...") assert head.synopsis == "In the beginning ..." @@ -235,6 +242,72 @@ def testCoreIndexData_IndexHeading(): assert head.synopsis == "How it started ..." +@pytest.mark.core +def testCoreIndexData_IndexHeadingReferences(): + """Test the IndexHeading references handling.""" + tags = TagsIndex() + head = IndexHeading(tags, "T0001") + + # Add some references + head.addReference("Jane", "@pov") + head.addReference("Jane", "@char") + head.addReference("John", "@char") + head.addReference("Main", "@plot") + head.addReference("Gun", "@object") + + # With no tagsIndex name set, these should be empty + assert head.getReferences() == { + "@entity": [], + "@plot": [], + "@object": [], + "@story": [], + "@tag": [], + "@focus": [], + "@custom": [], + "@time": [], + "@pov": [], + "@mention": [], + "@char": [], + "@location": [], + } + + # Set names + tags.add("Jane", "Jane", "0000000000000", "T00001", "CHARACTER") + tags.add("John", "John", "0000000000000", "T00001", "CHARACTER") + tags.add("Main", "Main", "0000000000000", "T00001", "PLOT") + tags.add("Gun", "Gun", "0000000000000", "T00001", "OBJECT") + + # Now they should be populated + assert head.getReferences() == { + "@entity": [], + "@plot": ["Main"], + "@object": ["Gun"], + "@story": [], + "@tag": [], + "@focus": [], + "@custom": [], + "@time": [], + "@pov": ["Jane"], + "@mention": [], + "@char": ["Jane", "John"], + "@location": [], + } + + # Check them individually + assert head.getReferencesByKeyword("@entity") == [] + assert head.getReferencesByKeyword("@plot") == ["Main"] + assert head.getReferencesByKeyword("@object") == ["Gun"] + assert head.getReferencesByKeyword("@story") == [] + assert head.getReferencesByKeyword("@tag") == [] + assert head.getReferencesByKeyword("@focus") == [] + assert head.getReferencesByKeyword("@custom") == [] + assert head.getReferencesByKeyword("@time") == [] + assert head.getReferencesByKeyword("@pov") == ["Jane"] + assert head.getReferencesByKeyword("@mention") == [] + assert head.getReferencesByKeyword("@char") == ["Jane", "John"] + assert head.getReferencesByKeyword("@location") == [] + + @pytest.mark.core def testCoreIndexData_IndexHeadingUnpackMeta(): """Test IndexHeading class meta unpacking.""" diff --git a/tests/test_core/test_core_novelmodel.py b/tests/test_core/test_core_novelmodel.py new file mode 100644 index 00000000..8fae210d --- /dev/null +++ b/tests/test_core/test_core_novelmodel.py @@ -0,0 +1,178 @@ +""" +novelWriter – Novel Model Tester +================================ + +This file is a part of novelWriter +Copyright (C) 2025 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 . +""" +from __future__ import annotations + +import pytest + +from PyQt6.QtCore import QModelIndex, Qt + +from novelwriter.core.indexdata import IndexHeading +from novelwriter.core.novelmodel import NovelModel +from novelwriter.core.project import NWProject +from novelwriter.enum import nwNovelExtra + +from tests.tools import C, buildTestProject + + +@pytest.mark.core +def testCoreNovelModel_Interface(nwGUI, fncPath, mockRnd): + """Test the novel model interface.""" + project = NWProject() + mockRnd.reset() + buildTestProject(project, fncPath) + + model = project.index.getNovelModel(C.hNovelRoot) + assert isinstance(model, NovelModel) + + root = QModelIndex() + assert root.row() == -1 + + # Initial structure + assert model.rowCount(root) == 3 + assert model.columnCount(root) == 3 + assert model.data(model.createIndex(0, 0), Qt.ItemDataRole.DisplayRole) == "New Novel" + assert model.handle(model.createIndex(0, 0)) == C.hTitlePage + assert model.key(model.createIndex(0, 0)) == "T0001" + + # Clear the model and check error handling + model.clear() + + assert model.data(root, Qt.ItemDataRole.DisplayRole) is None + assert model.handle(root) is None + assert model.key(root) is None + + +@pytest.mark.core +def testCoreNovelModel_Extra(nwGUI, fncPath, mockRnd): + """Test the novel model extra column.""" + project = NWProject() + mockRnd.reset() + buildTestProject(project, fncPath) + + root = QModelIndex() + model = project.index.getNovelModel(C.hNovelRoot) + assert isinstance(model, NovelModel) + assert model.rowCount(root) == 3 + + project.index._tagsIndex.add("Jane", "Jane", "0000000000000", "T0001", "CHARACTER") + project.index._tagsIndex.add("John", "John", "0000000000000", "T0001", "CHARACTER") + project.index._tagsIndex.add("Main", "Main", "0000000000000", "T0001", "PLOT") + project.index._tagsIndex.add("Side", "Side", "0000000000000", "T0001", "PLOT") + + scene = project.index._itemIndex[C.hSceneDoc] + assert scene is not None + scene.addHeadingRef("T0001", ["Jane"], "@pov") + scene.addHeadingRef("T0001", ["John"], "@focus") + scene.addHeadingRef("T0001", ["Main"], "@plot") + scene.addHeadingRef("T0001", ["Side"], "@plot") + + # No extra by default + assert model.columns == 3 + assert model._extraKey == "" + assert model._extraLabel == "" + + # Point of view + model.setExtraColumn(nwNovelExtra.POV) + model.refresh(scene) + assert model.columns == 4 + assert model._extraKey == "@pov" + assert model._extraLabel == "Point of View" + assert model.data(model.createIndex(2, 2), Qt.ItemDataRole.DisplayRole) == "Jane" + + model.setExtraColumn(nwNovelExtra.HIDDEN) + assert model.columns == 3 + + # Focus + model.setExtraColumn(nwNovelExtra.FOCUS) + model.refresh(scene) + assert model.columns == 4 + assert model._extraKey == "@focus" + assert model._extraLabel == "Focus" + assert model.data(model.createIndex(2, 2), Qt.ItemDataRole.DisplayRole) == "John" + + model.setExtraColumn(nwNovelExtra.HIDDEN) + assert model.columns == 3 + + # Plot + model.setExtraColumn(nwNovelExtra.PLOT) + model.refresh(scene) + assert model.columns == 4 + assert model._extraKey == "@plot" + assert model._extraLabel == "Plot" + assert model.data(model.createIndex(2, 2), Qt.ItemDataRole.DisplayRole) == "Main, Side" + + model.setExtraColumn(nwNovelExtra.HIDDEN) + assert model.columns == 3 + + +@pytest.mark.core +def testCoreNovelModel_Data(nwGUI, fncPath, mockRnd): + """Test the novel model data methods.""" + project = NWProject() + mockRnd.reset() + buildTestProject(project, fncPath) + + root = QModelIndex() + model = project.index.getNovelModel(C.hNovelRoot) + assert isinstance(model, NovelModel) + assert model.rowCount(root) == 3 + + title = project.index._itemIndex[C.hTitlePage] + chapter = project.index._itemIndex[C.hChapterDoc] + scene = project.index._itemIndex[C.hSceneDoc] + + assert title is not None + assert chapter is not None + assert scene is not None + + # Clear the model and try to refresh + model.clear() + assert model.rowCount(root) == 0 + + # Cannot refresh an empty model + assert model.refresh(title) is False + + # Add all back + model.append(title) + model.append(chapter) + model.append(scene) + + # Add headings to scene + scene.addHeading(IndexHeading(scene._tags, "T0002", 10, "H4", "A Section")) + scene.addHeading(IndexHeading(scene._tags, "T0003", 10, "H4", "Another Section")) + assert model.refresh(scene) is True + assert [ + model.data(model.createIndex(i, 0), Qt.ItemDataRole.DisplayRole) + for i in range(model.rowCount(root)) + ] == [ + "New Novel", "New Chapter", "New Scene", "A Section", "Another Section", + ] + + # Remove new headings + del scene._headings["T0002"] + del scene._headings["T0003"] + assert model.refresh(scene) is True + assert [ + model.data(model.createIndex(i, 0), Qt.ItemDataRole.DisplayRole) + for i in range(model.rowCount(root)) + ] == [ + "New Novel", "New Chapter", "New Scene", + ]