Add novel model test coverage
This commit is contained in:
@@ -818,13 +818,13 @@ class TagsIndex:
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
||||||
def tagName(self, tagKey: str) -> str:
|
def tagName(self, tagKey: str, default: str = "") -> str:
|
||||||
"""Get the name of a given tag."""
|
"""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."""
|
"""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:
|
def tagHandle(self, tagKey: str) -> str | None:
|
||||||
"""Get the handle of a given tag."""
|
"""Get the handle of a given tag."""
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ from novelwriter import SHARED
|
|||||||
from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst
|
from novelwriter.constants import nwKeyWords, nwLabels, nwStyles, trConst
|
||||||
from novelwriter.core.indexdata import IndexHeading, IndexNode
|
from novelwriter.core.indexdata import IndexHeading, IndexNode
|
||||||
from novelwriter.enum import nwNovelExtra
|
from novelwriter.enum import nwNovelExtra
|
||||||
from novelwriter.error import logException
|
|
||||||
from novelwriter.types import QtAlignRight
|
from novelwriter.types import QtAlignRight
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -169,38 +168,28 @@ class NovelModel(QAbstractTableModel):
|
|||||||
first = current[0]
|
first = current[0]
|
||||||
last = current[-1]
|
last = current[-1]
|
||||||
|
|
||||||
if len(current) != last - first + 1:
|
|
||||||
logger.warning("Novel model entries for '%s' are not continuous", handle)
|
|
||||||
return False
|
|
||||||
|
|
||||||
remains = []
|
remains = []
|
||||||
try:
|
for key, head in node.items():
|
||||||
for key, head in node.items():
|
if key != "T0000":
|
||||||
if key != "T0000":
|
if current:
|
||||||
if current:
|
j = current.pop(0)
|
||||||
j = current.pop(0)
|
self._rows[j] = self._generateEntry(handle, key, head)
|
||||||
self._rows[j] = self._generateEntry(handle, key, head)
|
else:
|
||||||
else:
|
remains.append((key, head))
|
||||||
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:
|
if remains:
|
||||||
self.beginInsertRows(QModelIndex(), last, last + len(remains) - 1)
|
# Inserting is safe for out of bounds indices
|
||||||
for k, (key, head) in enumerate(remains, last + 1):
|
self.beginInsertRows(QModelIndex(), last, last + len(remains) - 1)
|
||||||
self._rows.insert(k, self._generateEntry(handle, key, head))
|
for k, (key, head) in enumerate(remains, last + 1):
|
||||||
self.endInsertRows()
|
self._rows.insert(k, self._generateEntry(handle, key, head))
|
||||||
elif current:
|
self.endInsertRows()
|
||||||
self.beginRemoveRows(QModelIndex(), current[0], current[-1])
|
elif current:
|
||||||
del self._rows[current[0]:current[-1] + 1]
|
# Deleting ranges are safe for out of bounds indices
|
||||||
self.endRemoveRows()
|
self.beginRemoveRows(QModelIndex(), current[0], current[-1])
|
||||||
|
del self._rows[current[0]:current[-1] + 1]
|
||||||
except Exception:
|
self.endRemoveRows()
|
||||||
# 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
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from novelwriter import CONFIG
|
||||||
from novelwriter.core.index import TagsIndex
|
from novelwriter.core.index import TagsIndex
|
||||||
from novelwriter.core.indexdata import IndexHeading, IndexNode
|
from novelwriter.core.indexdata import IndexHeading, IndexNode
|
||||||
from novelwriter.core.item import NWItem
|
from novelwriter.core.item import NWItem
|
||||||
@@ -179,6 +180,7 @@ def testCoreIndexData_IndexHeading():
|
|||||||
assert head.charCount == 0
|
assert head.charCount == 0
|
||||||
assert head.wordCount == 0
|
assert head.wordCount == 0
|
||||||
assert head.paraCount == 0
|
assert head.paraCount == 0
|
||||||
|
assert head.mainCount == 0
|
||||||
assert head.synopsis == ""
|
assert head.synopsis == ""
|
||||||
assert head.tag == ""
|
assert head.tag == ""
|
||||||
assert head.references == {}
|
assert head.references == {}
|
||||||
@@ -205,6 +207,11 @@ def testCoreIndexData_IndexHeading():
|
|||||||
assert head.wordCount == 4
|
assert head.wordCount == 4
|
||||||
assert head.paraCount == 2
|
assert head.paraCount == 2
|
||||||
|
|
||||||
|
# Check Main Count
|
||||||
|
assert head.mainCount == 4
|
||||||
|
CONFIG.useCharCount = True
|
||||||
|
assert head.mainCount == 42
|
||||||
|
|
||||||
# Set Summary
|
# Set Summary
|
||||||
head.setSynopsis("In the beginning ...")
|
head.setSynopsis("In the beginning ...")
|
||||||
assert head.synopsis == "In the beginning ..."
|
assert head.synopsis == "In the beginning ..."
|
||||||
@@ -235,6 +242,72 @@ def testCoreIndexData_IndexHeading():
|
|||||||
assert head.synopsis == "How it started ..."
|
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
|
@pytest.mark.core
|
||||||
def testCoreIndexData_IndexHeadingUnpackMeta():
|
def testCoreIndexData_IndexHeadingUnpackMeta():
|
||||||
"""Test IndexHeading class meta unpacking."""
|
"""Test IndexHeading class meta unpacking."""
|
||||||
|
|||||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
"""
|
||||||
|
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",
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user