From 73451faa3109d82e547b2d0a87999f0580fb39a1 Mon Sep 17 00:00:00 2001 From: Veronica Berglyd Olsen <1619840+vkbo@users.noreply.github.com> Date: Sat, 17 Jun 2023 17:30:37 +0200 Subject: [PATCH] Add test coverage of build settings class --- novelwriter/core/buildsettings.py | 22 +- novelwriter/tools/manussettings.py | 8 +- tests/test_core/test_core_buildsettings.py | 363 +++++++++++++++++++++ 3 files changed, 376 insertions(+), 17 deletions(-) create mode 100644 tests/test_core/test_core_buildsettings.py diff --git a/novelwriter/core/buildsettings.py b/novelwriter/core/buildsettings.py index 7e4ca251..b4350af8 100644 --- a/novelwriter/core/buildsettings.py +++ b/novelwriter/core/buildsettings.py @@ -39,7 +39,6 @@ from novelwriter.enum import nwBuildFmt from novelwriter.error import logException from novelwriter.common import checkUuid, isHandle, jsonEncode from novelwriter.constants import nwFiles, nwHeadFmt -from novelwriter.core.item import NWItem from novelwriter.core.project import NWProject logger = logging.getLogger(__name__) @@ -208,15 +207,15 @@ class BuildSettings: def getInt(self, key: str) -> int: """Type safe value access for integers.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) - if isinstance(value, int): - return value + if isinstance(value, (int, float)): + return int(value) return 0 def getFloat(self, key: str) -> float: """Type safe value access for floats.""" value = self._settings.get(key, SETTINGS_TEMPLATE.get(key, (None, None)[1])) - if isinstance(value, float): - return value + if isinstance(value, (int, float)): + return float(value) return 0.0 ## @@ -282,8 +281,8 @@ class BuildSettings: self._changed = True return - def setSkipRoot(self, tHandle: str, state: bool): - """Set a specific root folder as skipped or not.""" + def setAllowRoot(self, tHandle: str, state: bool): + """Set a specific root folder as allowed or not.""" if state is True: self._skipRoot.discard(tHandle) self._changed = True @@ -299,7 +298,7 @@ class BuildSettings: definition = SETTINGS_TEMPLATE[key] if not isinstance(value, definition[0]): return False - if len(definition) == 4: + if len(definition) == 4 and isinstance(value, (int, float)): value = min(max(value, definition[2]), definition[3]) self._changed = value != self._settings[key] self._settings[key] = value @@ -330,10 +329,7 @@ class BuildSettings: for item in project.tree: tHandle = item.itemHandle - if not tHandle: - continue - if not isinstance(item, NWItem): - result[tHandle] = (False, FilterMode.UNKNOWN) + if tHandle is None: continue if item.isInactiveClass() or (item.itemRoot in self._skipRoot): result[tHandle] = (False, FilterMode.SKIPPED) @@ -401,7 +397,7 @@ class BuildSettings: self.setLastPath(data.get("path", None)) self.setLastBuildName(data.get("build", "")) - buildFmt = str(data.get("build", "")) + buildFmt = str(data.get("format", "")) if buildFmt in nwBuildFmt.__members__: self.setLastFormat(nwBuildFmt[buildFmt]) diff --git a/novelwriter/tools/manussettings.py b/novelwriter/tools/manussettings.py index 903284a0..02a32e20 100644 --- a/novelwriter/tools/manussettings.py +++ b/novelwriter/tools/manussettings.py @@ -423,7 +423,7 @@ class _FilterTab(QWidget): self._build.setValue(key[4:], state) self._setTreeItemMode() elif key.startswith("root:"): - self._build.setSkipRoot(key[5:], state) + self._build.setAllowRoot(key[5:], state) self._populateTree() return @@ -512,14 +512,14 @@ class _FilterTab(QWidget): # Root Classes self.filterOpt.addLabel(self.tr("Root Folders")) - for tHandle, nwItem in self.theProject.tree.iterRoots(None): + for nwItem in self.theProject.getProjectItems(): if not nwItem.isInactiveClass(): itemIcon = self.mainTheme.getItemIcon( nwItem.itemType, nwItem.itemClass, nwItem.itemLayout ) self.filterOpt.addItem( - itemIcon, nwItem.itemName, f"root:{tHandle}", - default=self._build.isRootAllowed(tHandle) + itemIcon, nwItem.itemName, f"root:{nwItem.itemHandle}", + default=self._build.isRootAllowed(nwItem.itemHandle) ) return diff --git a/tests/test_core/test_core_buildsettings.py b/tests/test_core/test_core_buildsettings.py new file mode 100644 index 00000000..1c19c32f --- /dev/null +++ b/tests/test_core/test_core_buildsettings.py @@ -0,0 +1,363 @@ +""" +novelWriter – Manuscript Build Settings Tester +============================================== + +This file is a part of novelWriter +Copyright 2018–2023, 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 . +""" + +import uuid +import pytest + +from pathlib import Path +from novelwriter.core.item import NWItem + +from tools import C, buildTestProject + +from novelwriter.enum import nwBuildFmt, nwItemClass +from novelwriter.core.project import NWProject +from novelwriter.core.buildsettings import BuildSettings, FilterMode + + +def isUUID(value): + """Checks if a value is a valid UUID object.""" + try: + uuid.UUID(value) + return True + except Exception: + return False + + +@pytest.mark.core +def testCoreBuildSettings_ClassAttributes(fncPath: Path): + """Test the BuildSettings class attributes.""" + build = BuildSettings() + assert build.changed is False + + # Name is always converted to string + build.setName(None) # type: ignore + assert build.name == "None" + + build.setName("Test Build") + assert build.name == "Test Build" + + # Only valid UUIDs are accpeted, anything else generates a new UUID + build.setBuildID("5cf45d24-f496-42c9-8733-529a9e52a62b") + assert build.buildID == "5cf45d24-f496-42c9-8733-529a9e52a62b" + + build.setBuildID(None) # type: ignore + assert build.buildID != "5cf45d24-f496-42c9-8733-529a9e52a62b" + assert isUUID(build.buildID) + + build.setBuildID("qcf45d24-f496-42c9-8733-529a9e52a62b") + assert build.buildID != "qcf45d24-f496-42c9-8733-529a9e52a62b" + assert isUUID(build.buildID) + + # Last path must be valid, if not it defaults to $HOME + build.setLastPath("/path/to/nowhere") + assert build.lastPath == Path.home() + + build.setLastPath(None) + assert build.lastPath == Path.home() + + (fncPath / "test.txt").write_text("foobar") + build.setLastPath(fncPath / "test.txt") # Can't be a file + assert build.lastPath == Path.home() + + build.setLastPath(fncPath) + assert build.lastPath == fncPath + + build.setLastPath(str(fncPath)) # String paths are also ok + assert build.lastPath == fncPath + + # Last path no longer exists -> fallback to $HOME + testDir = fncPath / "test_dir" + testDir.mkdir() + build.setLastPath(testDir) + assert build.lastPath == testDir + testDir.rmdir() + assert build.lastPath == Path.home() + + # Last build name + build.setLastBuildName(None) # type: ignore + assert build.lastBuildName == "None" + + build.setLastBuildName("\tBuild Name ") + assert build.lastBuildName == "Build Name" + + # Last build format + build.setLastFormat(None) # type: ignore + assert build.lastFormat == nwBuildFmt.ODT # Default value + + build.setLastFormat(nwBuildFmt.FODT) + assert build.lastFormat == nwBuildFmt.FODT + + # Changes are recorded + assert build.changed is True + build.resetChangedState() + assert build.changed is False + + # Set some sensible values + build.setName("Test Build") + build.setBuildID("5cf45d24-f496-42c9-8733-529a9e52a62b") + build.setLastPath(fncPath) + build.setLastBuildName("Build Name") + build.setLastFormat(nwBuildFmt.HTML) + + # Pack the values + data = build.pack() + assert data["name"] == "Test Build" + assert data["uuid"] == "5cf45d24-f496-42c9-8733-529a9e52a62b" + assert data["path"] == str(fncPath) + assert data["build"] == "Build Name" + assert data["format"] == nwBuildFmt.HTML.name + + # Unpack into new object + another = BuildSettings() + another.unpack(data) + more = another.pack() + assert more["name"] == "Test Build" + assert more["uuid"] == "5cf45d24-f496-42c9-8733-529a9e52a62b" + assert more["path"] == str(fncPath) + assert more["build"] == "Build Name" + assert more["format"] == nwBuildFmt.HTML.name + +# END Test testCoreBuildSettings_ClassAttributes + + +@pytest.mark.core +def testCoreBuildSettings_BuildValues(): + """Test BuildSettings get/set of build values.""" + build = BuildSettings() + + strSetting = "headings.fmtTitle" + intSetting = "format.textSize" + boolSetting = "filter.includeNovel" + floatSetting = "format.lineHeight" + + # Invalid setting + assert build.setValue("foo", "bar") is False + + # Value must be correct type + assert build.setValue(strSetting, 15) is False + assert build.setValue(intSetting, 15.0) is False + assert build.setValue(boolSetting, "string") is False + assert build.setValue(floatSetting, 15) is False + + # Check min/max range + assert build.setValue(floatSetting, 200.0) is True + assert build.getFloat(floatSetting) == 3.0 + assert build.setValue(floatSetting, 0.0) is True + assert build.getFloat(floatSetting) == 0.75 + + # Check string values + assert build.setValue(strSetting, "foobar") is True + assert build.getStr(strSetting) == "foobar" + assert build.getInt(strSetting) == 0 + assert build.getBool(strSetting) is True + assert build.getFloat(strSetting) == 0.0 + + # Check int values + assert build.setValue(intSetting, 42) is True + assert build.getStr(intSetting) == "42" + assert build.getInt(intSetting) == 42 + assert build.getBool(intSetting) is True + assert build.getFloat(intSetting) == 42.0 + + # Check bool values + assert build.setValue(boolSetting, True) is True + assert build.getStr(boolSetting) == "True" + assert build.getInt(boolSetting) == 1 + assert build.getBool(boolSetting) is True + assert build.getFloat(boolSetting) == 1.0 + + # Check float values + assert build.setValue(floatSetting, 2.5) is True + assert build.getStr(floatSetting) == "2.5" + assert build.getInt(floatSetting) == 2 + assert build.getBool(floatSetting) is True + assert build.getFloat(floatSetting) == 2.5 + + # Check labels + assert build.getLabel(strSetting) == "Title Headings" + assert BuildSettings.getLabel(strSetting) == "Title Headings" + + # Pack the values + data = build.pack() + assert data["settings"][strSetting] == "foobar" + assert data["settings"][intSetting] == 42 + assert data["settings"][boolSetting] is True + assert data["settings"][floatSetting] == 2.5 + + # Unpack into new object + another = BuildSettings() + another.unpack(data) + more = another.pack() + assert more["settings"][strSetting] == "foobar" + assert more["settings"][intSetting] == 42 + assert more["settings"][boolSetting] is True + assert more["settings"][floatSetting] == 2.5 + +# END Test testCoreBuildSettings_BuildValues + + +@pytest.mark.core +def testCoreBuildSettings_Filters(mockGUI, fncPath: Path, mockRnd): + """Test filters for project items.""" + project = NWProject(mockGUI) + buildTestProject(project, fncPath) + build = BuildSettings() + + # Add some more items + hArchRoot = project.newRoot(nwItemClass.ARCHIVE, "Archive") + hCharDoc = project.newFile("Jane Doe", C.hCharRoot) + initLen = len(project.tree) + + # With no changes + assert build.isRootAllowed(C.hNovelRoot) is True + assert build.isRootAllowed(C.hPlotRoot) is True + assert build.isRootAllowed(C.hCharRoot) is True + assert build.isRootAllowed(C.hWorldRoot) is True + + assert build.buildItemFilter(project, withRoots=False) == { + C.hNovelRoot: (False, FilterMode.SKIPPED), + C.hPlotRoot: (False, FilterMode.SKIPPED), + C.hCharRoot: (False, FilterMode.SKIPPED), + C.hWorldRoot: (False, FilterMode.SKIPPED), + C.hTitlePage: (True, FilterMode.FILTERED), + C.hChapterDir: (False, FilterMode.SKIPPED), + C.hChapterDoc: (True, FilterMode.FILTERED), + C.hSceneDoc: (True, FilterMode.FILTERED), + hArchRoot: (False, FilterMode.SKIPPED), + hCharDoc: (False, FilterMode.FILTERED), + } + + # Enable notes and roots + build.setValue("filter.includeNotes", True) + assert build.buildItemFilter(project, withRoots=True) == { + C.hNovelRoot: (True, FilterMode.ROOT), + C.hPlotRoot: (True, FilterMode.ROOT), + C.hCharRoot: (True, FilterMode.ROOT), + C.hWorldRoot: (True, FilterMode.ROOT), + C.hTitlePage: (True, FilterMode.FILTERED), + C.hChapterDir: (False, FilterMode.SKIPPED), + C.hChapterDoc: (True, FilterMode.FILTERED), + C.hSceneDoc: (True, FilterMode.FILTERED), + hArchRoot: (False, FilterMode.SKIPPED), + hCharDoc: (True, FilterMode.FILTERED), + } + + # Skip plot and char roots + build.setValue("filter.includeNotes", True) + build.setAllowRoot(C.hPlotRoot, False) + build.setAllowRoot(C.hCharRoot, False) + assert build.isRootAllowed(C.hNovelRoot) is True + assert build.isRootAllowed(C.hPlotRoot) is False + assert build.isRootAllowed(C.hCharRoot) is False + assert build.isRootAllowed(C.hWorldRoot) is True + assert build.buildItemFilter(project, withRoots=True) == { + C.hNovelRoot: (True, FilterMode.ROOT), + C.hPlotRoot: (False, FilterMode.SKIPPED), + C.hCharRoot: (False, FilterMode.SKIPPED), + C.hWorldRoot: (True, FilterMode.ROOT), + C.hTitlePage: (True, FilterMode.FILTERED), + C.hChapterDir: (False, FilterMode.SKIPPED), + C.hChapterDoc: (True, FilterMode.FILTERED), + C.hSceneDoc: (True, FilterMode.FILTERED), + hArchRoot: (False, FilterMode.SKIPPED), + hCharDoc: (False, FilterMode.SKIPPED), # Now also skipped since in char + } + + # Enable char root again, but exclude char doc and scene doc + build.setValue("filter.includeNotes", True) + build.setAllowRoot(C.hPlotRoot, False) + build.setAllowRoot(C.hCharRoot, True) + build.setExcluded(C.hSceneDoc) + build.setExcluded(hCharDoc) # type: ignore + assert build.isRootAllowed(C.hNovelRoot) is True + assert build.isRootAllowed(C.hPlotRoot) is False + assert build.isRootAllowed(C.hCharRoot) is True + assert build.isRootAllowed(C.hWorldRoot) is True + assert build.buildItemFilter(project, withRoots=True) == { + C.hNovelRoot: (True, FilterMode.ROOT), + C.hPlotRoot: (False, FilterMode.SKIPPED), + C.hCharRoot: (True, FilterMode.ROOT), + C.hWorldRoot: (True, FilterMode.ROOT), + C.hTitlePage: (True, FilterMode.FILTERED), + C.hChapterDir: (False, FilterMode.SKIPPED), + C.hChapterDoc: (True, FilterMode.FILTERED), + C.hSceneDoc: (False, FilterMode.EXCLUDED), + hArchRoot: (False, FilterMode.SKIPPED), + hCharDoc: (False, FilterMode.EXCLUDED), + } + + # Disable notes, but include char doc + build.setValue("filter.includeNotes", False) + build.setAllowRoot(C.hPlotRoot, False) + build.setAllowRoot(C.hCharRoot, True) + build.setExcluded(C.hSceneDoc) + build.setIncluded(hCharDoc) # type: ignore + assert build.isRootAllowed(C.hNovelRoot) is True + assert build.isRootAllowed(C.hPlotRoot) is False + assert build.isRootAllowed(C.hCharRoot) is True + assert build.isRootAllowed(C.hWorldRoot) is True + assert build.buildItemFilter(project, withRoots=True) == { + C.hNovelRoot: (True, FilterMode.ROOT), + C.hPlotRoot: (False, FilterMode.SKIPPED), + C.hCharRoot: (True, FilterMode.ROOT), + C.hWorldRoot: (True, FilterMode.ROOT), + C.hTitlePage: (True, FilterMode.FILTERED), + C.hChapterDir: (False, FilterMode.SKIPPED), + C.hChapterDoc: (True, FilterMode.FILTERED), + C.hSceneDoc: (False, FilterMode.EXCLUDED), + hArchRoot: (False, FilterMode.SKIPPED), + hCharDoc: (True, FilterMode.INCLUDED), + } + + # Set everything back to filered + build.setValue("filter.includeNotes", False) + build.setAllowRoot(C.hPlotRoot, False) + build.setAllowRoot(C.hCharRoot, True) + build.setFiltered(C.hSceneDoc) + build.setFiltered(hCharDoc) # type: ignore + assert build.isRootAllowed(C.hNovelRoot) is True + assert build.isRootAllowed(C.hPlotRoot) is False + assert build.isRootAllowed(C.hCharRoot) is True + assert build.isRootAllowed(C.hWorldRoot) is True + assert build.buildItemFilter(project, withRoots=True) == { + C.hNovelRoot: (True, FilterMode.ROOT), + C.hPlotRoot: (False, FilterMode.SKIPPED), + C.hCharRoot: (True, FilterMode.ROOT), + C.hWorldRoot: (True, FilterMode.ROOT), + C.hTitlePage: (True, FilterMode.FILTERED), + C.hChapterDir: (False, FilterMode.SKIPPED), + C.hChapterDoc: (True, FilterMode.FILTERED), + C.hSceneDoc: (True, FilterMode.FILTERED), + hArchRoot: (False, FilterMode.SKIPPED), + hCharDoc: (False, FilterMode.FILTERED), + } + + # Check error handling + project.tree._treeOrder.append("00000000000ff") + project.tree._projTree["00000000000ff"] = NWItem(project) + assert project.tree["00000000000ff"].itemHandle is None # type: ignore + filtered = build.buildItemFilter(project, withRoots=False) + assert len(filtered) == initLen + + # No valid project provided + assert build.buildItemFilter(None) == {} # type: ignore + +# END Test testCoreBuildSettings_Filters