From 906ab78a6a5992eb7f7994e5f4dbcf68d1c967b2 Mon Sep 17 00:00:00 2001 From: Bart Feenstra Date: Tue, 7 May 2024 13:32:57 +0100 Subject: [PATCH] Increase test coverage of the Nginx extension (#1467) --- betty/extension/nginx/__init__.py | 178 +----------------- betty/extension/nginx/config.py | 82 ++++++++ betty/extension/nginx/gui.py | 100 ++++++++++ betty/tests/conftest.py | 33 +++- betty/tests/extension/nginx/test___init__.py | 2 +- betty/tests/extension/nginx/test_config.py | 70 +++++++ betty/tests/extension/nginx/test_gui.py | 89 +++++++++ .../tests/extension/nginx/test_integration.py | 2 +- betty/tests/extension/nginx/test_serve.py | 2 +- 9 files changed, 377 insertions(+), 181 deletions(-) create mode 100644 betty/extension/nginx/config.py create mode 100644 betty/extension/nginx/gui.py create mode 100644 betty/tests/extension/nginx/test_config.py create mode 100644 betty/tests/extension/nginx/test_gui.py diff --git a/betty/extension/nginx/__init__.py b/betty/extension/nginx/__init__.py index d00b76a05..42dcb0f8b 100644 --- a/betty/extension/nginx/__init__.py +++ b/betty/extension/nginx/__init__.py @@ -2,110 +2,20 @@ from collections.abc import Sequence from pathlib import Path -from typing import Any, Self -from PyQt6.QtWidgets import ( - QFormLayout, - QButtonGroup, - QRadioButton, - QWidget, - QHBoxLayout, - QLineEdit, - QFileDialog, - QPushButton, -) - -from betty.app import App from betty.app.extension import ConfigurableExtension -from betty.config import Configuration from betty.extension.nginx.artifact import ( generate_configuration_file, generate_dockerfile_file, ) +from betty.extension.nginx.config import NginxConfiguration +from betty.extension.nginx.gui import _NginxGuiWidget from betty.generate import Generator, GenerationContext from betty.gui import GuiBuilder -from betty.gui.error import ExceptionCatcher from betty.locale import Str -from betty.serde.dump import Dump, VoidableDump, minimize, Void, VoidableDictDump -from betty.serde.load import Asserter, Fields, OptionalField, Assertions from betty.serve import ServerProvider, Server -class NginxConfiguration(Configuration): - def __init__( - self, - *, - www_directory_path: str | None = None, - https: bool | None = None, - ): - super().__init__() - self._https = https - self.www_directory_path = www_directory_path - - @property - def https(self) -> bool | None: - return self._https - - @https.setter - def https(self, https: bool | None) -> None: - self._https = https - self._dispatch_change() - - @property - def www_directory_path(self) -> str | None: - return self._www_directory_path - - @www_directory_path.setter - def www_directory_path(self, www_directory_path: str | None) -> None: - self._www_directory_path = www_directory_path - self._dispatch_change() - - def update(self, other: Self) -> None: - self._https = other._https - self._www_directory_path = other._www_directory_path - self._dispatch_change() - - @classmethod - def load( - cls, - dump: Dump, - configuration: Self | None = None, - ) -> Self: - if configuration is None: - configuration = cls() - asserter = Asserter() - asserter.assert_record( - Fields( - OptionalField( - "https", - Assertions( - asserter.assert_or( - asserter.assert_bool(), asserter.assert_none() - ) - ) - | asserter.assert_setattr(configuration, "https"), - ), - OptionalField( - "www_directory_path", - Assertions(asserter.assert_str()) - | asserter.assert_setattr(configuration, "www_directory_path"), - ), - ) - )(dump) - return configuration - - def dump(self) -> VoidableDump: - dump: VoidableDictDump[VoidableDump] = { - "https": self.https, - "www_directory_path": ( - Void - if self.www_directory_path is None - else str(self.www_directory_path) - ), - } - return minimize(dump, True) - - class Nginx( ConfigurableExtension[NginxConfiguration], Generator, ServerProvider, GuiBuilder ): @@ -151,87 +61,5 @@ def www_directory_path(self) -> str: self._app.project.configuration.www_directory_path ) - def gui_build(self) -> QWidget: + def gui_build(self) -> _NginxGuiWidget: return _NginxGuiWidget(self._app, self._configuration) - - -class _NginxGuiWidget(QWidget): - def __init__( - self, app: App, configuration: NginxConfiguration, *args: Any, **kwargs: Any - ): - super().__init__(*args, **kwargs) - self._app = app - self._configuration = configuration - layout = QFormLayout() - - self.setLayout(layout) - - https_button_group = QButtonGroup() - - def _update_configuration_https_base_url(checked: bool) -> None: - if checked: - self._configuration.https = None - - self._nginx_https_base_url = QRadioButton( - "Use HTTPS and HTTP/2 if the site's URL starts with https://" - ) - self._nginx_https_base_url.setChecked(self._configuration.https is None) - self._nginx_https_base_url.toggled.connect(_update_configuration_https_base_url) - layout.addRow(self._nginx_https_base_url) - https_button_group.addButton(self._nginx_https_base_url) - - def _update_configuration_https_https(checked: bool) -> None: - if checked: - self._configuration.https = True - - self._nginx_https_https = QRadioButton("Use HTTPS and HTTP/2") - self._nginx_https_https.setChecked(self._configuration.https is True) - self._nginx_https_https.toggled.connect(_update_configuration_https_https) - layout.addRow(self._nginx_https_https) - https_button_group.addButton(self._nginx_https_https) - - def _update_configuration_https_http(checked: bool) -> None: - if checked: - self._configuration.https = False - - self._nginx_https_http = QRadioButton("Use HTTP") - self._nginx_https_http.setChecked(self._configuration.https is False) - self._nginx_https_http.toggled.connect(_update_configuration_https_http) - layout.addRow(self._nginx_https_http) - https_button_group.addButton(self._nginx_https_http) - - def _update_configuration_www_directory_path(www_directory_path: str) -> None: - self._configuration.www_directory_path = ( - None - if www_directory_path == "" - or www_directory_path - == str(self._app.project.configuration.www_directory_path) - else www_directory_path - ) - - self._nginx_www_directory_path = QLineEdit() - self._nginx_www_directory_path.setText( - str(self._configuration.www_directory_path) - if self._configuration.www_directory_path is not None - else str(self._app.project.configuration.www_directory_path) - ) - self._nginx_www_directory_path.textChanged.connect( - _update_configuration_www_directory_path - ) - www_directory_path_layout = QHBoxLayout() - www_directory_path_layout.addWidget(self._nginx_www_directory_path) - - def find_www_directory_path() -> None: - with ExceptionCatcher(self): - found_www_directory_path = QFileDialog.getExistingDirectory( - self, - "Serve your site from...", - directory=self._nginx_www_directory_path.text(), - ) - if "" != found_www_directory_path: - self._nginx_www_directory_path.setText(found_www_directory_path) - - self._nginx_www_directory_path_find = QPushButton("...") - self._nginx_www_directory_path_find.released.connect(find_www_directory_path) - www_directory_path_layout.addWidget(self._nginx_www_directory_path_find) - layout.addRow("WWW directory", www_directory_path_layout) diff --git a/betty/extension/nginx/config.py b/betty/extension/nginx/config.py new file mode 100644 index 000000000..a94c2c114 --- /dev/null +++ b/betty/extension/nginx/config.py @@ -0,0 +1,82 @@ +"""Integrate Betty with `nginx `_.""" + +from typing import Self + +from betty.config import Configuration +from betty.serde.dump import Dump, VoidableDump, minimize, Void, VoidableDictDump +from betty.serde.load import Asserter, Fields, OptionalField, Assertions + + +class NginxConfiguration(Configuration): + def __init__( + self, + *, + www_directory_path: str | None = None, + https: bool | None = None, + ): + super().__init__() + self._https = https + self.www_directory_path = www_directory_path + + @property + def https(self) -> bool | None: + return self._https + + @https.setter + def https(self, https: bool | None) -> None: + self._https = https + self._dispatch_change() + + @property + def www_directory_path(self) -> str | None: + return self._www_directory_path + + @www_directory_path.setter + def www_directory_path(self, www_directory_path: str | None) -> None: + self._www_directory_path = www_directory_path + self._dispatch_change() + + def update(self, other: Self) -> None: + self._https = other._https + self._www_directory_path = other._www_directory_path + self._dispatch_change() + + @classmethod + def load( + cls, + dump: Dump, + configuration: Self | None = None, + ) -> Self: + if configuration is None: + configuration = cls() + asserter = Asserter() + asserter.assert_record( + Fields( + OptionalField( + "https", + Assertions( + asserter.assert_or( + asserter.assert_bool(), asserter.assert_none() + ) + ) + | asserter.assert_setattr(configuration, "https"), + ), + OptionalField( + "www_directory_path", + Assertions(asserter.assert_str()) + | asserter.assert_setattr(configuration, "www_directory_path"), + ), + ) + )(dump) + return configuration + + def dump(self) -> VoidableDump: + dump: VoidableDictDump[VoidableDump] = { + "https": self.https, + "www_directory_path": ( + Void + if self.www_directory_path is None + else str(self.www_directory_path) + ), + } + return minimize(dump, True) diff --git a/betty/extension/nginx/gui.py b/betty/extension/nginx/gui.py new file mode 100644 index 000000000..5d7cf6cbc --- /dev/null +++ b/betty/extension/nginx/gui.py @@ -0,0 +1,100 @@ +"""Integrate Betty with `nginx `_.""" + +from typing import Any + +from PyQt6.QtWidgets import ( + QFormLayout, + QButtonGroup, + QRadioButton, + QWidget, + QHBoxLayout, + QLineEdit, + QFileDialog, + QPushButton, +) + +from betty.app import App +from betty.extension.nginx.config import NginxConfiguration +from betty.gui.error import ExceptionCatcher + + +class _NginxGuiWidget(QWidget): + def __init__( + self, app: App, configuration: NginxConfiguration, *args: Any, **kwargs: Any + ): + super().__init__(*args, **kwargs) + self._app = app + self._configuration = configuration + layout = QFormLayout() + + self.setLayout(layout) + + https_button_group = QButtonGroup() + + def _update_configuration_https_base_url(checked: bool) -> None: + if checked: + self._configuration.https = None + + self._nginx_https_base_url = QRadioButton( + "Use HTTPS and HTTP/2 if the site's URL starts with https://" + ) + self._nginx_https_base_url.setChecked(self._configuration.https is None) + self._nginx_https_base_url.toggled.connect(_update_configuration_https_base_url) + layout.addRow(self._nginx_https_base_url) + https_button_group.addButton(self._nginx_https_base_url) + + def _update_configuration_https_https(checked: bool) -> None: + if checked: + self._configuration.https = True + + self._nginx_https_https = QRadioButton("Use HTTPS and HTTP/2") + self._nginx_https_https.setChecked(self._configuration.https is True) + self._nginx_https_https.toggled.connect(_update_configuration_https_https) + layout.addRow(self._nginx_https_https) + https_button_group.addButton(self._nginx_https_https) + + def _update_configuration_https_http(checked: bool) -> None: + if checked: + self._configuration.https = False + + self._nginx_https_http = QRadioButton("Use HTTP") + self._nginx_https_http.setChecked(self._configuration.https is False) + self._nginx_https_http.toggled.connect(_update_configuration_https_http) + layout.addRow(self._nginx_https_http) + https_button_group.addButton(self._nginx_https_http) + + def _update_configuration_www_directory_path(www_directory_path: str) -> None: + self._configuration.www_directory_path = ( + None + if www_directory_path == "" + or www_directory_path + == str(self._app.project.configuration.www_directory_path) + else www_directory_path + ) + + self._nginx_www_directory_path = QLineEdit() + self._nginx_www_directory_path.setText( + str(self._configuration.www_directory_path) + if self._configuration.www_directory_path is not None + else str(self._app.project.configuration.www_directory_path) + ) + self._nginx_www_directory_path.textChanged.connect( + _update_configuration_www_directory_path + ) + www_directory_path_layout = QHBoxLayout() + www_directory_path_layout.addWidget(self._nginx_www_directory_path) + + def find_www_directory_path() -> None: + with ExceptionCatcher(self): + found_www_directory_path = QFileDialog.getExistingDirectory( + self, + "Serve your site from...", + directory=self._nginx_www_directory_path.text(), + ) + if "" != found_www_directory_path: + self._nginx_www_directory_path.setText(found_www_directory_path) + + self._nginx_www_directory_path_find = QPushButton("...") + self._nginx_www_directory_path_find.released.connect(find_www_directory_path) + www_directory_path_layout.addWidget(self._nginx_www_directory_path_find) + layout.addRow("WWW directory", www_directory_path_layout) diff --git a/betty/tests/conftest.py b/betty/tests/conftest.py index f67799d0b..af42857c7 100644 --- a/betty/tests/conftest.py +++ b/betty/tests/conftest.py @@ -7,13 +7,21 @@ import logging from collections.abc import AsyncIterator, Iterator from pathlib import Path -from typing import TypeVar, cast +from typing import TypeVar, cast, TypeGuard from warnings import filterwarnings import pytest from PyQt6.QtCore import Qt, QObject from PyQt6.QtGui import QAction -from PyQt6.QtWidgets import QMainWindow, QMenu, QWidget +from PyQt6.QtWidgets import ( + QMainWindow, + QMenu, + QWidget, + QLineEdit, + QTextEdit, + QAbstractButton, + QGroupBox, +) from _pytest.logging import LogCaptureFixture from pytestqt.qtbot import QtBot @@ -91,8 +99,11 @@ def _is_interactive(self, item: QAction | QWidget | None) -> bool: return False return True - def assert_interactive(self, item: QAction | QWidget | None) -> None: + def assert_interactive( + self, item: QAction | QWidget | None + ) -> TypeGuard[QAction | QWidget]: self.qtbot.wait_until(lambda: self._is_interactive(item)) + return True def assert_not_interactive(self, item: QAction | QWidget | None) -> None: self.qtbot.wait_until(lambda: not self._is_interactive(item)) @@ -216,6 +227,22 @@ def mouse_click( self.assert_interactive(widget) self.qtbot.mouseClick(widget, button) + def set_text(self, widget: QLineEdit | QTextEdit | None, text: str) -> None: + """ + Set (input) text for a form widget. + """ + if self.assert_interactive(widget): + widget.setText(text) + + def set_checked( + self, widget: QAbstractButton | QGroupBox | None, checked: bool + ) -> None: + """ + Check or uncheck a form widget. + """ + if self.assert_interactive(widget): + widget.setChecked(checked) + @pytest.fixture async def betty_qtbot( diff --git a/betty/tests/extension/nginx/test___init__.py b/betty/tests/extension/nginx/test___init__.py index 6e406d735..b92cae74c 100644 --- a/betty/tests/extension/nginx/test___init__.py +++ b/betty/tests/extension/nginx/test___init__.py @@ -3,7 +3,7 @@ from betty.app import App from betty.extension import Nginx -from betty.extension.nginx import NginxConfiguration +from betty.extension.nginx.config import NginxConfiguration from betty.generate import generate from betty.project import ExtensionConfiguration, LocaleConfiguration diff --git a/betty/tests/extension/nginx/test_config.py b/betty/tests/extension/nginx/test_config.py new file mode 100644 index 000000000..7ab906eca --- /dev/null +++ b/betty/tests/extension/nginx/test_config.py @@ -0,0 +1,70 @@ +from pathlib import Path +from typing import Any + +import pytest + +from betty.extension.nginx.config import NginxConfiguration +from betty.serde.dump import Dump +from betty.serde.load import AssertionFailed +from betty.tests.serde import raises_error + + +class TestNginxConfiguration: + async def test_load_with_minimal_configuration(self) -> None: + dump: dict[str, Any] = {} + NginxConfiguration().load(dump) + + async def test_load_without_dict_should_error(self) -> None: + dump = None + with raises_error(error_type=AssertionFailed): + NginxConfiguration().load(dump) + + @pytest.mark.parametrize( + "https", + [ + None, + True, + False, + ], + ) + async def test_load_with_https(self, https: bool | None) -> None: + dump: Dump = { + "https": https, + } + sut = NginxConfiguration.load(dump) + assert sut.https == https + + async def test_load_with_www_directory_path(self, tmp_path: Path) -> None: + www_directory_path = str(tmp_path) + dump: Dump = { + "www_directory_path": www_directory_path, + } + sut = NginxConfiguration.load(dump) + assert sut.www_directory_path == www_directory_path + + async def test_dump_with_minimal_configuration(self) -> None: + sut = NginxConfiguration() + expected = { + "https": None, + } + assert expected == sut.dump() + + async def test_dump_with_www_directory_path(self, tmp_path: Path) -> None: + www_directory_path = str(tmp_path) + sut = NginxConfiguration() + sut.www_directory_path = www_directory_path + expected = { + "https": None, + "www_directory_path": www_directory_path, + } + assert expected == sut.dump() + + async def test_update(self, tmp_path: Path) -> None: + www_directory_path = str(tmp_path) + sut = NginxConfiguration() + other = NginxConfiguration() + other.https = True + other.www_directory_path = www_directory_path + sut.update(other) + assert sut.https is True + assert sut.www_directory_path == www_directory_path diff --git a/betty/tests/extension/nginx/test_gui.py b/betty/tests/extension/nginx/test_gui.py new file mode 100644 index 000000000..a34a15fbd --- /dev/null +++ b/betty/tests/extension/nginx/test_gui.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path + +from PyQt6.QtWidgets import QFileDialog +from pytest_mock import MockerFixture + +from betty.extension import Nginx +from betty.tests.conftest import BettyQtBot + + +class TestNginxGuiWidget: + async def test_https_with_base_url( + self, + betty_qtbot: BettyQtBot, + ) -> None: + betty_qtbot.app.project.configuration.extensions.enable(Nginx) + nginx = betty_qtbot.app.extensions[Nginx] + nginx.configuration.https = False + sut = nginx.gui_build() + betty_qtbot.qtbot.addWidget(sut) + sut.show() + + betty_qtbot.set_checked(sut._nginx_https_base_url, True) + assert nginx.configuration.https is None + + async def test_https_with_https( + self, + betty_qtbot: BettyQtBot, + ) -> None: + betty_qtbot.app.project.configuration.extensions.enable(Nginx) + nginx = betty_qtbot.app.extensions[Nginx] + nginx.configuration.https = False + sut = nginx.gui_build() + betty_qtbot.qtbot.addWidget(sut) + sut.show() + + betty_qtbot.set_checked(sut._nginx_https_https, True) + assert nginx.configuration.https is True + + async def test_https_with_http( + self, + betty_qtbot: BettyQtBot, + ) -> None: + betty_qtbot.app.project.configuration.extensions.enable(Nginx) + nginx = betty_qtbot.app.extensions[Nginx] + nginx.configuration.https = True + sut = nginx.gui_build() + betty_qtbot.qtbot.addWidget(sut) + sut.show() + + betty_qtbot.set_checked(sut._nginx_https_http, True) + assert nginx.configuration.https is False + + async def test_www_directory_path_with_path( + self, + betty_qtbot: BettyQtBot, + mocker: MockerFixture, + tmp_path: Path, + ) -> None: + betty_qtbot.app.project.configuration.extensions.enable(Nginx) + nginx = betty_qtbot.app.extensions[Nginx] + sut = nginx.gui_build() + betty_qtbot.qtbot.addWidget(sut) + sut.show() + + www_directory_path = str(tmp_path) + mocker.patch.object( + QFileDialog, + "getExistingDirectory", + mocker.MagicMock(return_value=www_directory_path), + ) + + betty_qtbot.mouse_click(sut._nginx_www_directory_path_find) + assert nginx.configuration.www_directory_path == www_directory_path + + async def test_www_directory_path_without_path( + self, + betty_qtbot: BettyQtBot, + tmp_path: Path, + ) -> None: + betty_qtbot.app.project.configuration.extensions.enable(Nginx) + nginx = betty_qtbot.app.extensions[Nginx] + sut = nginx.gui_build() + betty_qtbot.qtbot.addWidget(sut) + sut.show() + + betty_qtbot.set_text(sut._nginx_www_directory_path, "") + assert nginx.configuration.www_directory_path is None diff --git a/betty/tests/extension/nginx/test_integration.py b/betty/tests/extension/nginx/test_integration.py index 691534e4d..ac0fb6945 100644 --- a/betty/tests/extension/nginx/test_integration.py +++ b/betty/tests/extension/nginx/test_integration.py @@ -11,7 +11,7 @@ from betty import generate from betty.app import App from betty.extension import Nginx -from betty.extension.nginx import NginxConfiguration +from betty.extension.nginx.config import NginxConfiguration from betty.extension.nginx.serve import DockerizedNginxServer from betty.functools import Do from betty.json.schema import Schema diff --git a/betty/tests/extension/nginx/test_serve.py b/betty/tests/extension/nginx/test_serve.py index 78068abcb..de363b4e6 100644 --- a/betty/tests/extension/nginx/test_serve.py +++ b/betty/tests/extension/nginx/test_serve.py @@ -8,7 +8,7 @@ from betty.app import App from betty.extension import Nginx -from betty.extension.nginx import NginxConfiguration +from betty.extension.nginx.config import NginxConfiguration from betty.extension.nginx.serve import DockerizedNginxServer from betty.functools import Do from betty.project import ExtensionConfiguration