Add full test coverage of index class
This commit is contained in:
@@ -34,7 +34,7 @@ def trConst(tString):
|
||||
return QCoreApplication.translate("Constant", tString)
|
||||
|
||||
|
||||
class nwConst():
|
||||
class nwConst:
|
||||
|
||||
# Date and Time Formats
|
||||
FMT_TSTAMP = "%Y-%m-%d %H:%M:%S" # Default format
|
||||
@@ -48,7 +48,7 @@ class nwConst():
|
||||
# END Class nwConst
|
||||
|
||||
|
||||
class nwRegEx():
|
||||
class nwRegEx:
|
||||
|
||||
FMT_EI = r"(?<![\w\\])(_)(?![\s_])(.+?)(?<![\s\\])(\1)(?!\w)"
|
||||
FMT_EB = r"(?<![\w\\])([\*]{2})(?![\s\*])(.+?)(?<![\s\\])(\1)(?!\w)"
|
||||
@@ -57,7 +57,7 @@ class nwRegEx():
|
||||
# END Class nwRegEx
|
||||
|
||||
|
||||
class nwFiles():
|
||||
class nwFiles:
|
||||
|
||||
PROJ_FILE = "nwProject.nwx"
|
||||
PROJ_DICT = "wordlist.txt"
|
||||
@@ -107,7 +107,7 @@ class nwKeyWords:
|
||||
# END Class nwKeyWords
|
||||
|
||||
|
||||
class nwLabels():
|
||||
class nwLabels:
|
||||
|
||||
CLASS_NAME = {
|
||||
nwItemClass.NO_CLASS: QT_TRANSLATE_NOOP("Constant", "None"),
|
||||
@@ -185,7 +185,7 @@ class nwLabels():
|
||||
# END Class nwLabels
|
||||
|
||||
|
||||
class nwQuotes():
|
||||
class nwQuotes:
|
||||
"""Allowed quotation marks.
|
||||
Source: https://en.wikipedia.org/wiki/Quotation_mark
|
||||
"""
|
||||
|
||||
+29
-15
@@ -67,12 +67,10 @@ class NWIndex:
|
||||
|
||||
self.theProject = theProject
|
||||
|
||||
# Internal
|
||||
self._indexBroken = False
|
||||
|
||||
# Indices
|
||||
# Storage and State
|
||||
self._tagsIndex = TagsIndex()
|
||||
self._itemIndex = ItemIndex(theProject)
|
||||
self._indexBroken = False
|
||||
|
||||
# TimeStamps
|
||||
self._timeNovel = 0
|
||||
@@ -81,6 +79,9 @@ class NWIndex:
|
||||
|
||||
return
|
||||
|
||||
def __repr__(self):
|
||||
return f"<NWIndex project='{self.theProject.projName}'>"
|
||||
|
||||
##
|
||||
# Properties
|
||||
##
|
||||
@@ -124,8 +125,7 @@ class NWIndex:
|
||||
|
||||
logger.debug("Re-indexing item '%s'", tHandle)
|
||||
theDoc = NWDoc(self.theProject, tHandle)
|
||||
theText = theDoc.readDocument()
|
||||
self.scanText(tHandle, theText if theText is not None else "")
|
||||
self.scanText(tHandle, theDoc.readDocument() or "")
|
||||
|
||||
return True
|
||||
|
||||
@@ -316,7 +316,7 @@ class NWIndex:
|
||||
return True
|
||||
|
||||
##
|
||||
# Internal Indexers
|
||||
# Internal Indexer Helpers
|
||||
##
|
||||
|
||||
def _indexTitle(self, tHandle, aLine, nTitle):
|
||||
@@ -613,11 +613,13 @@ class NWIndex:
|
||||
|
||||
|
||||
# =============================================================================================== #
|
||||
# Indexer Objects
|
||||
# The Tags Index Object
|
||||
# =============================================================================================== #
|
||||
|
||||
class TagsIndex:
|
||||
"""A wrapper class that holds the reverse lookup tags index.
|
||||
"""A wrapper class that holds the reverse lookup tags index. This is
|
||||
just a simple wrapper around a single dictionary to keep tighter
|
||||
control of the keys.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
@@ -719,8 +721,16 @@ class TagsIndex:
|
||||
# END Class TagsIndex
|
||||
|
||||
|
||||
# =============================================================================================== #
|
||||
# The Item Index Objects
|
||||
# =============================================================================================== #
|
||||
|
||||
class ItemIndex:
|
||||
"""A wrapper object holding the indexed items.
|
||||
"""A wrapper object holding the indexed items. This is a warapper
|
||||
class around a single storage dictionary with a set of utility
|
||||
functions for setting and accessing the index data. Each indexed
|
||||
item is stored in an IndexItem object, which again holds an
|
||||
IndexHeading object for each header of the text.
|
||||
"""
|
||||
|
||||
def __init__(self, theProject):
|
||||
@@ -896,7 +906,8 @@ class IndexItem:
|
||||
"""This object represents the index data of a project item (NWItem).
|
||||
It holds a record of all the headings in the text, and the meta data
|
||||
associated with each heading. It also holds a pointer to the project
|
||||
item.
|
||||
item. The main heading level of the item is also held here since it
|
||||
must be reset each time the item is re-indexed.
|
||||
"""
|
||||
|
||||
def __init__(self, tHandle, tItem):
|
||||
@@ -912,7 +923,7 @@ class IndexItem:
|
||||
return
|
||||
|
||||
def __repr__(self):
|
||||
return f"<IndexItem handle={self._handle}>"
|
||||
return f"<IndexItem handle='{self._handle}'>"
|
||||
|
||||
##
|
||||
# Properties
|
||||
@@ -1062,7 +1073,7 @@ class IndexHeading:
|
||||
return
|
||||
|
||||
def __repr__(self):
|
||||
return f"<IndexHeading key={self._key}>"
|
||||
return f"<IndexHeading key='{self._key}'>"
|
||||
|
||||
##
|
||||
# Properties
|
||||
@@ -1165,8 +1176,11 @@ class IndexHeading:
|
||||
|
||||
def packReferences(self):
|
||||
"""Pack references into a dictionary for saving to cache.
|
||||
Multiple types are packed into a sorted, comma separated string.
|
||||
It is sorted to prevent creating unnecessary diffs as the order
|
||||
of a set is not guaranteed.
|
||||
"""
|
||||
return {key: ",".join(value) for key, value in self._refs.items()}
|
||||
return {key: ",".join(sorted(list(value))) for key, value in self._refs.items()}
|
||||
|
||||
def unpackData(self, data):
|
||||
"""Unpack a heading entry from a dictionary.
|
||||
@@ -1189,7 +1203,7 @@ class IndexHeading:
|
||||
if not isinstance(tagKey, str):
|
||||
raise ValueError("itemIndex reference key must be a string")
|
||||
if not isinstance(refTypes, str):
|
||||
raise ValueError("itemIndex reference types must be a string")
|
||||
raise ValueError("itemIndex reference type must be a string")
|
||||
for refType in refTypes.split(","):
|
||||
if refType in nwKeyWords.VALID_KEYS:
|
||||
self.addReference(tagKey, refType)
|
||||
|
||||
@@ -29,7 +29,7 @@ from mock import causeException
|
||||
from tools import buildTestProject, cmpFiles, writeFile
|
||||
|
||||
from novelwriter.core.project import NWProject
|
||||
from novelwriter.core.index import NWIndex, countWords
|
||||
from novelwriter.core.index import NWIndex, countWords, TagsIndex
|
||||
from novelwriter.enum import nwItemClass, nwItemLayout
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ def testCoreIndex_LoadSave(monkeypatch, nwLipsum, mockGUI, outDir, refDir):
|
||||
assert theProject.openProject(nwLipsum)
|
||||
|
||||
theIndex = NWIndex(theProject)
|
||||
assert repr(theIndex) == "<NWIndex project='Lorem Ipsum'>"
|
||||
|
||||
notIndexable = {
|
||||
"b3643d0f92e32": False, # Novel ROOT
|
||||
"45e6b01ca35c1": False, # Chapter One FOLDER
|
||||
@@ -761,6 +763,166 @@ def testCoreIndex_ExtractData(mockGUI, fncDir, mockRnd):
|
||||
# END Test testCoreIndex_ExtractData
|
||||
|
||||
|
||||
@pytest.mark.core
|
||||
def testCoreIndex_TagsIndex():
|
||||
"""Check the TagsIndex class.
|
||||
"""
|
||||
tagsIndex = TagsIndex()
|
||||
assert tagsIndex._tags == {}
|
||||
|
||||
# Expected data
|
||||
content = {
|
||||
"Tag1": {
|
||||
"handle": "0000000000001",
|
||||
"heading": "T000001",
|
||||
"class": nwItemClass.NOVEL.name,
|
||||
},
|
||||
"Tag2": {
|
||||
"handle": "0000000000002",
|
||||
"heading": "T000002",
|
||||
"class": nwItemClass.CHARACTER.name,
|
||||
},
|
||||
"Tag3": {
|
||||
"handle": "0000000000003",
|
||||
"heading": "T000003",
|
||||
"class": nwItemClass.PLOT.name,
|
||||
},
|
||||
}
|
||||
|
||||
# Add data
|
||||
tagsIndex.add("Tag1", "0000000000001", "T000001", nwItemClass.NOVEL)
|
||||
tagsIndex.add("Tag2", "0000000000002", "T000002", nwItemClass.CHARACTER)
|
||||
tagsIndex.add("Tag3", "0000000000003", "T000003", nwItemClass.PLOT)
|
||||
assert tagsIndex._tags == content
|
||||
|
||||
# Get items
|
||||
assert tagsIndex["Tag1"] == content["Tag1"]
|
||||
assert tagsIndex["Tag2"] == content["Tag2"]
|
||||
assert tagsIndex["Tag3"] == content["Tag3"]
|
||||
assert tagsIndex["Tag4"] is None
|
||||
|
||||
# Contains
|
||||
assert "Tag1" in tagsIndex
|
||||
assert "Tag2" in tagsIndex
|
||||
assert "Tag3" in tagsIndex
|
||||
assert "Tag4" not in tagsIndex
|
||||
|
||||
# Read back handles
|
||||
assert tagsIndex.tagHandle("Tag1") == "0000000000001"
|
||||
assert tagsIndex.tagHandle("Tag2") == "0000000000002"
|
||||
assert tagsIndex.tagHandle("Tag3") == "0000000000003"
|
||||
assert tagsIndex.tagHandle("Tag4") is None
|
||||
|
||||
# Read back headings
|
||||
assert tagsIndex.tagHeading("Tag1") == "T000001"
|
||||
assert tagsIndex.tagHeading("Tag2") == "T000002"
|
||||
assert tagsIndex.tagHeading("Tag3") == "T000003"
|
||||
assert tagsIndex.tagHeading("Tag4") == "T000000"
|
||||
|
||||
# Read back classes
|
||||
assert tagsIndex.tagClass("Tag1") == nwItemClass.NOVEL.name
|
||||
assert tagsIndex.tagClass("Tag2") == nwItemClass.CHARACTER.name
|
||||
assert tagsIndex.tagClass("Tag3") == nwItemClass.PLOT.name
|
||||
assert tagsIndex.tagClass("Tag4") is None
|
||||
|
||||
# Pack Data
|
||||
assert tagsIndex.packData() == content
|
||||
|
||||
# Delete the second key and a nomn-existant key
|
||||
del tagsIndex["Tag2"]
|
||||
del tagsIndex["Tag4"]
|
||||
assert "Tag1" in tagsIndex
|
||||
assert "Tag2" not in tagsIndex
|
||||
assert "Tag3" in tagsIndex
|
||||
assert "Tag4" not in tagsIndex
|
||||
|
||||
# Clear and reload
|
||||
tagsIndex.clear()
|
||||
assert tagsIndex._tags == {}
|
||||
assert tagsIndex.packData() == {}
|
||||
|
||||
tagsIndex.unpackData(content)
|
||||
assert tagsIndex._tags == content
|
||||
assert tagsIndex.packData() == content
|
||||
|
||||
# Unpack Errors
|
||||
# =============
|
||||
tagsIndex.clear()
|
||||
|
||||
# Invalid data type
|
||||
with pytest.raises(ValueError):
|
||||
tagsIndex.unpackData([])
|
||||
|
||||
# Invalid key
|
||||
with pytest.raises(ValueError):
|
||||
tagsIndex.unpackData({
|
||||
1234: {
|
||||
"handle": "0000000000001",
|
||||
"heading": "T000001",
|
||||
"class": "NOVEL",
|
||||
}
|
||||
})
|
||||
|
||||
# Missing handle
|
||||
with pytest.raises(KeyError):
|
||||
tagsIndex.unpackData({
|
||||
"Tag1": {
|
||||
"heading": "T000001",
|
||||
"class": "NOVEL",
|
||||
}
|
||||
})
|
||||
|
||||
# Missing heading
|
||||
with pytest.raises(KeyError):
|
||||
tagsIndex.unpackData({
|
||||
"Tag1": {
|
||||
"handle": "0000000000001",
|
||||
"class": "NOVEL",
|
||||
}
|
||||
})
|
||||
|
||||
# Missing class
|
||||
with pytest.raises(KeyError):
|
||||
tagsIndex.unpackData({
|
||||
"Tag1": {
|
||||
"handle": "0000000000001",
|
||||
"heading": "T000001",
|
||||
}
|
||||
})
|
||||
|
||||
# Invalid handle
|
||||
with pytest.raises(ValueError):
|
||||
tagsIndex.unpackData({
|
||||
"Tag1": {
|
||||
"handle": "blablabla",
|
||||
"heading": "T000001",
|
||||
"class": "NOVEL",
|
||||
}
|
||||
})
|
||||
|
||||
# Invalid heading
|
||||
with pytest.raises(ValueError):
|
||||
tagsIndex.unpackData({
|
||||
"Tag1": {
|
||||
"handle": "0000000000001",
|
||||
"heading": "blabla",
|
||||
"class": "NOVEL",
|
||||
}
|
||||
})
|
||||
|
||||
# Invalid class
|
||||
with pytest.raises(ValueError):
|
||||
tagsIndex.unpackData({
|
||||
"Tag1": {
|
||||
"handle": "0000000000001",
|
||||
"heading": "T000001",
|
||||
"class": "blabla",
|
||||
}
|
||||
})
|
||||
|
||||
# END Test testCoreIndex_TagsIndex
|
||||
|
||||
|
||||
@pytest.mark.core
|
||||
def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd):
|
||||
"""Check the ItemIndex class.
|
||||
@@ -780,26 +942,6 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd):
|
||||
assert cHandle not in itemIndex
|
||||
assert sHandle not in itemIndex
|
||||
|
||||
# Unpack Data
|
||||
# ===========
|
||||
|
||||
# Data must be dictionary
|
||||
with pytest.raises(ValueError):
|
||||
itemIndex.unpackData("stuff")
|
||||
|
||||
# Keys must be valid handles
|
||||
with pytest.raises(ValueError):
|
||||
itemIndex.unpackData({"stuff": "more stuff"})
|
||||
|
||||
# Unknown keys should be skipped
|
||||
itemIndex.unpackData({"0000000000000": {}})
|
||||
assert itemIndex._items == {}
|
||||
|
||||
# Known keys can be added, even witout data
|
||||
itemIndex.unpackData({nHandle: {}})
|
||||
assert nHandle in itemIndex
|
||||
itemIndex.clear()
|
||||
|
||||
# Add Items
|
||||
# =========
|
||||
assert cHandle not in itemIndex
|
||||
@@ -820,7 +962,7 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd):
|
||||
# Set the remainig data values
|
||||
itemIndex.setHeadingCounts(cHandle, "T000001", 60, 10, 2)
|
||||
itemIndex.setHeadingSynopsis(cHandle, "T000001", "In the beginning ...")
|
||||
itemIndex.setHeadingTag(cHandle, "T000001", "One") # Although it isn't allowed to have a tag
|
||||
itemIndex.setHeadingTag(cHandle, "T000001", "One")
|
||||
itemIndex.addHeadingReferences(cHandle, "T000001", ["Jane"], "@pov")
|
||||
itemIndex.addHeadingReferences(cHandle, "T000001", ["Jane"], "@focus")
|
||||
itemIndex.addHeadingReferences(cHandle, "T000001", ["Jane", "John"], "@char")
|
||||
@@ -842,6 +984,37 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd):
|
||||
itemIndex.addItemHeading(nHandle, "T000001", "H1", "Novel")
|
||||
itemIndex.addItemHeading(sHandle, "T000001", "H3", "Scene One")
|
||||
|
||||
# Check Item and Heading Direct Access
|
||||
# ====================================
|
||||
|
||||
# Check repr strings
|
||||
assert repr(itemIndex[nHandle]) == f"<IndexItem handle='{nHandle}'>"
|
||||
assert repr(itemIndex[nHandle]["T000001"]) == "<IndexHeading key='T000001'>"
|
||||
|
||||
# Check content of a single item
|
||||
assert "T000001" in itemIndex[nHandle]
|
||||
assert itemIndex[cHandle].allTags() == ["One"]
|
||||
|
||||
# Check the content of a single heading
|
||||
assert itemIndex[cHandle]["T000001"].key == "T000001"
|
||||
assert itemIndex[cHandle]["T000001"].level == "H2"
|
||||
assert itemIndex[cHandle]["T000001"].title == "Chapter One"
|
||||
assert itemIndex[cHandle]["T000001"].tag == "One"
|
||||
assert itemIndex[cHandle]["T000001"].charCount == 60
|
||||
assert itemIndex[cHandle]["T000001"].wordCount == 10
|
||||
assert itemIndex[cHandle]["T000001"].paraCount == 2
|
||||
assert itemIndex[cHandle]["T000001"].synopsis == "In the beginning ..."
|
||||
assert "Jane" in itemIndex[cHandle]["T000001"].references
|
||||
assert "John" in itemIndex[cHandle]["T000001"].references
|
||||
|
||||
# Check heading level setter
|
||||
itemIndex[cHandle]["T000001"].setLevel("H3") # Change it
|
||||
assert itemIndex[cHandle]["T000001"].level == "H3"
|
||||
itemIndex[cHandle]["T000001"].setLevel("H2") # Set it back
|
||||
assert itemIndex[cHandle]["T000001"].level == "H2"
|
||||
itemIndex[cHandle]["T000001"].setLevel("H5") # Invalid level
|
||||
assert itemIndex[cHandle]["T000001"].level == "H2"
|
||||
|
||||
# Data Extraction
|
||||
# ===============
|
||||
|
||||
@@ -908,6 +1081,82 @@ def testCoreIndex_ItemIndex(mockGUI, fncDir, mockRnd):
|
||||
del itemIndex[uHandle]
|
||||
assert uHandle not in itemIndex
|
||||
|
||||
# Unpack Error Handling
|
||||
# =====================
|
||||
|
||||
# Pack/unpack should restore state
|
||||
content = itemIndex.packData()
|
||||
itemIndex.clear()
|
||||
itemIndex.unpackData(content)
|
||||
assert itemIndex.packData() == content
|
||||
itemIndex.clear()
|
||||
|
||||
# Data must be dictionary
|
||||
with pytest.raises(ValueError):
|
||||
itemIndex.unpackData("stuff")
|
||||
|
||||
# Keys must be valid handles
|
||||
with pytest.raises(ValueError):
|
||||
itemIndex.unpackData({"stuff": "more stuff"})
|
||||
|
||||
# Unknown keys should be skipped
|
||||
itemIndex.unpackData({"0000000000000": {}})
|
||||
assert itemIndex._items == {}
|
||||
|
||||
# Known keys can be added, even witout data
|
||||
itemIndex.unpackData({nHandle: {}})
|
||||
assert nHandle in itemIndex
|
||||
|
||||
# Title tags must be valid
|
||||
with pytest.raises(ValueError):
|
||||
itemIndex.unpackData({cHandle: {"headings": {"TTTTTTT": {}}}})
|
||||
|
||||
# Reference without a heading should be rejected
|
||||
itemIndex.unpackData({
|
||||
cHandle: {
|
||||
"headings": {"T000001": {}},
|
||||
"references": {"T000001": {}, "T000002": {}},
|
||||
}
|
||||
})
|
||||
assert "T000001" in itemIndex[cHandle]
|
||||
assert "T000002" not in itemIndex[cHandle]
|
||||
itemIndex.clear()
|
||||
|
||||
# Tag keys must be strings
|
||||
with pytest.raises(ValueError):
|
||||
itemIndex.unpackData({
|
||||
cHandle: {
|
||||
"headings": {"T000001": {}},
|
||||
"references": {"T000001": {1234: "@pov"}},
|
||||
}
|
||||
})
|
||||
|
||||
# Type must be strings
|
||||
with pytest.raises(ValueError):
|
||||
itemIndex.unpackData({
|
||||
cHandle: {
|
||||
"headings": {"T000001": {}},
|
||||
"references": {"T000001": {"John": []}},
|
||||
}
|
||||
})
|
||||
|
||||
# Types must be valid
|
||||
with pytest.raises(ValueError):
|
||||
itemIndex.unpackData({
|
||||
cHandle: {
|
||||
"headings": {"T000001": {}},
|
||||
"references": {"T000001": {"John": "@pov,@char,@stuff"}},
|
||||
}
|
||||
})
|
||||
|
||||
# This should pass
|
||||
itemIndex.unpackData({
|
||||
cHandle: {
|
||||
"headings": {"T000001": {}},
|
||||
"references": {"T000001": {"John": "@pov,@char"}},
|
||||
}
|
||||
})
|
||||
|
||||
# END Test testCoreIndex_ItemIndex
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user