diff --git a/novelwriter/guimain.py b/novelwriter/guimain.py index 0d4e90a8..d606b8d3 100644 --- a/novelwriter/guimain.py +++ b/novelwriter/guimain.py @@ -1392,7 +1392,7 @@ class GuiMain(QMainWindow): userIdle = qApp.applicationState() != Qt.ApplicationActive self.mainStatus.setUserIdle(editIdle or userIdle) SHARED.updateIdleTime(currTime, editIdle or userIdle) - self.mainStatus.updateTime(idleTime=SHARED.idleTime) + self.mainStatus.updateTime(idleTime=SHARED.projectIdleTime) return @pyqtSlot() diff --git a/novelwriter/shared.py b/novelwriter/shared.py index 07ea2d81..eea5fe26 100644 --- a/novelwriter/shared.py +++ b/novelwriter/shared.py @@ -43,7 +43,7 @@ logger = logging.getLogger(__name__) class SharedData(QObject): __slots__ = ( - "_gui", "_theme", "_project", "_leckedBy", "_alert", + "_gui", "_theme", "_project", "_lockedBy", "_alert", "_idleTime", "_idleRefTime", ) @@ -58,28 +58,28 @@ class SharedData(QObject): self._lockedBy = None self._alert = None self._idleTime = 0.0 - self._idleRedTime = time() + self._idleRefTime = time() return @property def mainGui(self) -> GuiMain: """Return the Main GUI instance.""" if self._gui is None: - raise Exception("UserData class not properly initialised") + raise Exception("SharedData class not fully initialised") return self._gui @property def theme(self) -> GuiTheme: """Return the GUI Theme instance.""" if self._theme is None: - raise Exception("UserData class not properly initialised") + raise Exception("SharedData class not fully initialised") return self._theme @property def project(self) -> NWProject: """Return the active NWProject instance.""" if self._project is None: - raise Exception("UserData class not properly initialised") + raise Exception("SharedData class not fully initialised") return self._project @property @@ -92,16 +92,16 @@ class SharedData(QObject): """Return cached lock information for the last project.""" return self._lockedBy + @property + def projectIdleTime(self) -> float: + """Return the session idle time.""" + return self._idleTime + @property def alert(self) -> _GuiAlert | None: """Return a pointer to the last alert box.""" return self._alert - @property - def idleTime(self) -> float: - """Return the session idle time.""" - return self._idleTime - ## # Methods ## @@ -154,12 +154,9 @@ class SharedData(QObject): the last time this function was called. Otherwise, only the reference time is updated. """ - if hasattr(self, "_idleRefTime"): - # This method is called by a timer from C++, and the - # instance may not have be initialised - if userIdle: - self._idleTime += currTime - self._idleRefTime - self._idleRefTime = currTime + if userIdle: + self._idleTime += currTime - self._idleRefTime + self._idleRefTime = currTime return ## diff --git a/tests/conftest.py b/tests/conftest.py index a21551a8..4d6e6e72 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,7 @@ along with this program. If not, see . import sys import pytest import shutil +import logging from pathlib import Path @@ -62,6 +63,7 @@ def resetConfigVars(): @pytest.fixture(scope="session", autouse=True) def sessionFixture(): """A session wide fixture to set up the test environment.""" + logging.root.setLevel(logging.INFO) if _TMP_ROOT.exists(): shutil.rmtree(_TMP_ROOT) _TMP_ROOT.mkdir() @@ -81,6 +83,7 @@ def functionFixture(qtbot): CONFIG.__init__() CONFIG.initConfig(confPath=_TMP_CONF, dataPath=_TMP_CONF) resetConfigVars() + logging.getLogger("novelwriter").setLevel(logging.INFO) return diff --git a/tests/test_base/test_base_init.py b/tests/test_base/test_base_init.py index b6e09d15..cbc20c51 100644 --- a/tests/test_base/test_base_init.py +++ b/tests/test_base/test_base_init.py @@ -30,8 +30,7 @@ from novelwriter import CONFIG, main, logger @pytest.mark.base def testBaseInit_Launch(caplog, monkeypatch, fncPath): - """Check launching the main GUI. - """ + """Check launching the main GUI.""" monkeypatch.setattr("novelwriter.guimain.GuiMain", MockGuiMain) # TestMode Launch @@ -80,8 +79,7 @@ def testBaseInit_Launch(caplog, monkeypatch, fncPath): @pytest.mark.base def testBaseInit_Options(monkeypatch, fncPath): - """Test command line options for logging level. - """ + """Test command line options for logging level.""" monkeypatch.setattr("novelwriter.guimain.GuiMain", MockGuiMain) monkeypatch.setattr(sys, "argv", [ "novelWriter.py", "--testmode", f"--config={fncPath}", f"--data={fncPath}" @@ -146,8 +144,7 @@ def testBaseInit_Options(monkeypatch, fncPath): @pytest.mark.base def testBaseInit_Imports(caplog, monkeypatch, fncPath): - """Check import error handling. - """ + """Check import error handling.""" monkeypatch.setattr("novelwriter.guimain.GuiMain", MockGuiMain) monkeypatch.setattr("PyQt5.QtWidgets.QApplication.__init__", lambda *a: None) monkeypatch.setattr("PyQt5.QtWidgets.QApplication.exec_", lambda *a: 0) diff --git a/tests/test_base/test_base_shared.py b/tests/test_base/test_base_shared.py new file mode 100644 index 00000000..80f80954 --- /dev/null +++ b/tests/test_base/test_base_shared.py @@ -0,0 +1,187 @@ +""" +novelWriter – SharedData Class 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 pytest + +from mocked import MockGuiMain, MockTheme + +from PyQt5.QtWidgets import QMessageBox + +from novelwriter.core.project import NWProject +from novelwriter.shared import SharedData, _GuiAlert +from tests.tools import buildTestProject + + +@pytest.mark.base +def testBaseSharedData_Init(): + """Test SharedData class initialisation.""" + shared = SharedData() + + # When not initialised, it should raise exceptions + with pytest.raises(Exception): + shared.mainGui + with pytest.raises(Exception): + shared.theme + with pytest.raises(Exception): + shared.project + + # Create some mock objects + mockGui = MockGuiMain() + mockTheme = MockTheme() + assert mockGui is not mockTheme + + # Properly initialise the class + shared.initSharedData(mockGui, mockTheme) # type: ignore + + assert shared.mainGui is mockGui + assert shared.theme is mockTheme + + assert isinstance(shared.project, NWProject) + assert shared.hasProject is False + assert shared.projectIdleTime == 0.0 + assert shared.projectLock is None + + assert shared.alert is None + +# END Test testBaseSharedData_Init + + +@pytest.mark.base +def testBaseSharedData_Projects(fncPath, caplog: pytest.LogCaptureFixture): + """Test SharedData handling of projects.""" + project = NWProject() + buildTestProject(project, fncPath) + project.closeProject(0.0) # Clears the lockfile + + shared = SharedData() + assert shared._project is None + + # Initialise the instance, should create an empty project + mockGui = MockGuiMain() + mockTheme = MockTheme() + shared.initSharedData(mockGui, mockTheme) # type: ignore + assert isinstance(shared.project, NWProject) + assert shared.hasProject is False + + # Load the test project + assert shared.openProject(fncPath) is True + assert shared.hasProject is True + + # We cannot open two projects + caplog.clear() + assert shared.openProject(fncPath) is False + assert caplog.messages[-1] == "A project is already open" + assert shared._idleTime == 0.0 + + # Update idle time + refTime = shared._idleRefTime + shared.updateIdleTime(refTime + 1.0, False) + shared.updateIdleTime(refTime + 2.0, True) + shared.updateIdleTime(refTime + 3.0, False) + shared.updateIdleTime(refTime + 4.0, True) + assert round(shared.projectIdleTime) == 2 + + # Save project + assert shared.saveProject() is True + + # Close project + shared.closeProject() + assert shared.hasProject is False + + # Cannot save a project after it's been closed + assert shared.saveProject() is False + + # Check locked project info + project.openProject(fncPath) # First open with our independent project instance + assert shared.hasProject is False + assert shared.projectLock is None + assert shared.openProject(fncPath) is False # Then with out shared instance + assert shared.hasProject is False + assert isinstance(shared.projectLock, list) + +# END Test testBaseSharedData_Projects + + +@pytest.mark.base +def testBaseSharedData_Alerts(monkeypatch, caplog: pytest.LogCaptureFixture): + """Test SharedData class alert helper functions.""" + monkeypatch.setattr(QMessageBox, "exec_", lambda *a: None) + monkeypatch.setattr(QMessageBox, "result", lambda *a: QMessageBox.Yes) + + shared = SharedData() + + mockGui = MockGuiMain() + mockTheme = MockTheme() + shared.initSharedData(mockGui, mockTheme) # type: ignore + + assert shared.alert is None + + # Info box + caplog.clear() + shared.info("Hello World", info="foo", details="bar") + assert isinstance(shared.alert, _GuiAlert) + assert shared.alert.text() == "Hello World" + assert shared.alert.informativeText() == "foo" + assert shared.alert.detailedText() == "bar" + assert caplog.text.strip().startswith("INFO") + assert caplog.text.strip().endswith("Hello World foo bar") + shared._alert = None + + # Warning box + caplog.clear() + shared.warn("Oops!", info="foo", details="bar") + assert isinstance(shared.alert, _GuiAlert) + assert shared.alert.text() == "Oops!" + assert shared.alert.informativeText() == "foo" + assert shared.alert.detailedText() == "bar" + assert caplog.text.strip().startswith("WARNING") + assert caplog.text.strip().endswith("Oops! foo bar") + shared._alert = None + + # Error box + caplog.clear() + shared.error("Oh noes!", info="foo", details="bar") + assert isinstance(shared.alert, _GuiAlert) + assert shared.alert.text() == "Oh noes!" + assert shared.alert.informativeText() == "foo" + assert shared.alert.detailedText() == "bar" + assert caplog.text.strip().startswith("ERROR") + assert caplog.text.strip().endswith("Oh noes! foo bar") + shared._alert = None + + # Error box with exception + caplog.clear() + shared.error("Oh noes!", info="foo", details="bar", exc=Exception("Boom!")) + assert isinstance(shared.alert, _GuiAlert) + assert shared.alert.text() == "Oh noes!" + assert shared.alert.informativeText() == "foo
Exception: Boom!" + assert shared.alert.detailedText() == "bar" + assert caplog.text.strip().startswith("ERROR") + assert caplog.text.strip().endswith("Oh noes! foo bar") + shared._alert = None + + # Question box + assert shared.question("Why?") is True + assert isinstance(shared.alert, _GuiAlert) + assert shared.alert.text() == "Why?" + shared._alert = None + +# END Test testBaseSharedData_Alerts