diff --git a/resources/ui/gui_main.ui b/resources/ui/gui_main.ui index 38abb8c..2550bfa 100644 --- a/resources/ui/gui_main.ui +++ b/resources/ui/gui_main.ui @@ -102,6 +102,16 @@ + + + + Swap source and destination + + + Swap ⇅ + + + diff --git a/src/shortcircuit/app.py b/src/shortcircuit/app.py index 7fa37ca..a38adad 100644 --- a/src/shortcircuit/app.py +++ b/src/shortcircuit/app.py @@ -17,6 +17,7 @@ TYPE_EVESCOUT, TYPE_TRIPWIRE, default_configs, + is_configured, load_configs, migrate_legacy, save_configs, @@ -399,6 +400,14 @@ def __init__(self, parent=None): # Start version check self.version_thread.start() + # One mapper fetch shortly after launch when mappers are configured, so + # a returning user doesn't have to remember to fetch before routing. + # A single fetch per launch, not a recurring refresh, and never for a + # mapper missing its credentials (a fresh install's default Tripwire + # has none). + if self._mappers_ready(): + QtCore.QTimer.singleShot(1000, self._startup_fetch) + # noinspection PyUnresolvedReferences def additional_gui_setup(self): # Additional GUI setup @@ -451,6 +460,7 @@ def additional_gui_setup(self): self.btn_player_location_clicked ) self.pushButton_find_path.clicked.connect(self.btn_find_path_clicked) + self.pushButton_swap.clicked.connect(self.btn_swap_clicked) self.pushButton_trip_config.clicked.connect(self.btn_trip_config_clicked) self.pushButton_trip_get.clicked.connect(self.btn_trip_get_clicked) self.pushButton_system_avoid_add.clicked.connect( @@ -919,6 +929,14 @@ def login_handler(self, is_ok, char_name): self.state_eve_connection["error"] = "ESI error" if not is_ok else None self._status_eve_connection_update() + # Pre-fill the source with the pilot's location, so the usual flow after + # login is just: type a destination, Find path. Runs quietly: a failure + # doesn't pop a dialog, and a source the user already typed is kept + # (see location_handler). + if is_ok: + self._auto_location = True + self.esip.get_location() + @QtCore.Slot() def logout_handler(self): self.state_eve_connection["connected"] = False @@ -928,9 +946,14 @@ def logout_handler(self): @QtCore.Slot(str) def location_handler(self, location): + automatic = getattr(self, "_auto_location", False) + self._auto_location = False + if location: - self.lineEdit_source.setText(location) - else: + # The automatic fill after login never overwrites a typed source. + if not (automatic and self.lineEdit_source.text().strip()): + self.lineEdit_source.setText(location) + elif not automatic: self._message_box( "Player destination", "Unable to get location (character not online or ESI error)" @@ -978,6 +1001,7 @@ def btn_eve_login_clicked(self): @QtCore.Slot() def btn_player_location_clicked(self): + self._auto_location = False self.pushButton_player_location.setEnabled(False) self.esip.get_location() @@ -1004,6 +1028,21 @@ def btn_set_dest_clicked(self): def btn_find_path_clicked(self): self.find_path() + @QtCore.Slot() + def btn_swap_clicked(self): + source = self.lineEdit_source.text() + self.lineEdit_source.setText(self.lineEdit_destination.text()) + self.lineEdit_destination.setText(source) + + @QtCore.Slot() + def _startup_fetch(self): + if self._mappers_ready() and not self.worker_thread.isRunning(): + self.btn_trip_get_clicked() + + def _mappers_ready(self): + """Whether any enabled mapper has what it needs to fetch.""" + return any(cfg.enabled and is_configured(cfg) for cfg in self.mapper_configs) + @QtCore.Slot() def btn_trip_config_clicked(self): dialog = MappersDialog(self.mapper_configs, self.global_proxy, parent=self) diff --git a/src/shortcircuit/model/mapper_config.py b/src/shortcircuit/model/mapper_config.py index 5418e3c..0434cad 100644 --- a/src/shortcircuit/model/mapper_config.py +++ b/src/shortcircuit/model/mapper_config.py @@ -47,6 +47,21 @@ def from_dict(cls, data: dict) -> "MapperConfig": ) +def is_configured(cfg: MapperConfig) -> bool: + """ + Whether a config has what its mapper needs to fetch, decided from the + config alone. Building a source to call its validate_config() is not an + option: Tripwire logs in from its constructor, so asking would send the + very request this check exists to prevent. Mirrors each mapper's own + validate_config(); Eve Scout falls back to its public URL. + """ + if cfg.type == TYPE_TRIPWIRE: + return bool(cfg.url and cfg.user and cfg.password) + if cfg.type == TYPE_EVESCOUT: + return True + return False + + def load_configs(settings: QtCore.QSettings) -> List[MapperConfig]: raw = settings.value(SETTINGS_KEY) if not raw: diff --git a/src/shortcircuit/model/navigation.py b/src/shortcircuit/model/navigation.py index ec0aef3..616193f 100644 --- a/src/shortcircuit/model/navigation.py +++ b/src/shortcircuit/model/navigation.py @@ -5,7 +5,7 @@ from .evedb import EveDb, SystemDescription, WormholeMassspan, WormholeSize, WormholeTimespan from .evescout import EveScout from .mapper_base import MapperSource -from .mapper_config import TYPE_EVESCOUT, TYPE_TRIPWIRE, MapperConfig +from .mapper_config import TYPE_EVESCOUT, TYPE_TRIPWIRE, MapperConfig, is_configured from .mapper_registry import MapperRegistry from .solarmap import ConnectionType, SolarMap from .tripwire import Tripwire @@ -61,7 +61,8 @@ def setup_mappers(self): """ Rebuild the registry from app_obj.mapper_configs. - Disabled configs and unknown types are skipped (unknown types are + Disabled configs, unknown types and configs missing what their + mapper needs (see is_configured) are skipped (unknown types are logged but non-fatal, so a settings file from a newer version won't crash an older binary). Instantiation errors on one config don't prevent the rest from being registered. @@ -78,6 +79,10 @@ def setup_mappers(self): if builder is None: Logger.warning(f"Unknown mapper type '{cfg.type}' for '{cfg.name}', skipping") continue + if not is_configured(cfg): + # Never build it: building a Tripwire source is its login request. + Logger.info(f"Mapper '{cfg.name}' is not set up yet, skipping") + continue try: source = builder(cfg) except Exception as e: diff --git a/src/shortcircuit/model/test_launch_flow.py b/src/shortcircuit/model/test_launch_flow.py new file mode 100644 index 0000000..fcbe340 --- /dev/null +++ b/src/shortcircuit/model/test_launch_flow.py @@ -0,0 +1,124 @@ +# test_launch_flow.py +"""Auto-location after login, the swap button, and the startup fetch. + +These exercise MainWindow's handlers on a stand-in object holding just the +widgets they touch, so no settings, database or network are involved. +""" + +import os + +# The suite is headless: select the Qt platform plugin before Qt is imported. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from unittest import mock + +import pytest +from PySide6 import QtWidgets + + +@pytest.fixture(scope="session") +def qapp(): + app = QtWidgets.QApplication.instance() or QtWidgets.QApplication([]) + yield app + + +class _Window: + """The parts of MainWindow the flow handlers read and write.""" + + def __init__(self): + from shortcircuit.app import MainWindow + self.lineEdit_source = QtWidgets.QLineEdit() + self.lineEdit_destination = QtWidgets.QLineEdit() + self.pushButton_player_location = QtWidgets.QPushButton() + self.state_eve_connection = {"connected": False, "char_name": None, "error": None} + self.esip = mock.Mock() + self.worker_thread = mock.Mock() + self.worker_thread.isRunning.return_value = False + self._message_box = mock.Mock() + self._status_eve_connection_update = mock.Mock() + self.btn_trip_get_clicked = mock.Mock() + from shortcircuit.model.mapper_config import TYPE_EVESCOUT, MapperConfig + self.mapper_configs = [MapperConfig(type=TYPE_EVESCOUT, name="Eve Scout")] + for name in ( + "login_handler", "location_handler", "btn_player_location_clicked", + "btn_swap_clicked", "_startup_fetch", "_mappers_ready", + ): + setattr(self, name, getattr(MainWindow, name).__get__(self)) + + +def test_login_fetches_location_and_fills_empty_source(qapp): + w = _Window() + w.login_handler(True, "Cormorant Fell") + w.esip.get_location.assert_called_once() + w.location_handler("Perimeter") + assert w.lineEdit_source.text() == "Perimeter" + + +def test_failed_login_does_not_fetch_location(qapp): + w = _Window() + w.login_handler(False, None) + w.esip.get_location.assert_not_called() + + +def test_automatic_fill_never_overwrites_a_typed_source(qapp): + w = _Window() + w.lineEdit_source.setText("Jita") + w.login_handler(True, "Cormorant Fell") + w.location_handler("Perimeter") + assert w.lineEdit_source.text() == "Jita" + + +def test_automatic_failure_is_silent_but_manual_failure_reports(qapp): + w = _Window() + w.login_handler(True, "Cormorant Fell") + w.location_handler("") # pilot offline / ESI error + w._message_box.assert_not_called() + + w.btn_player_location_clicked() + w.location_handler("") + w._message_box.assert_called_once() + + +def test_manual_location_overwrites_the_source(qapp): + w = _Window() + w.lineEdit_source.setText("Jita") + w.btn_player_location_clicked() + w.location_handler("Perimeter") + assert w.lineEdit_source.text() == "Perimeter" + + +def test_swap_exchanges_source_and_destination(qapp): + w = _Window() + w.lineEdit_source.setText("Perimeter") + w.lineEdit_destination.setText("Amarr") + w.btn_swap_clicked() + assert (w.lineEdit_source.text(), w.lineEdit_destination.text()) == ("Amarr", "Perimeter") + + +def test_startup_fetch_runs_once_unless_a_fetch_is_already_running(qapp): + w = _Window() + w._startup_fetch() + w.btn_trip_get_clicked.assert_called_once() + + w.btn_trip_get_clicked.reset_mock() + w.worker_thread.isRunning.return_value = True + w._startup_fetch() + w.btn_trip_get_clicked.assert_not_called() + + +def test_startup_fetch_skips_a_mapper_without_credentials(qapp): + # A fresh install: the default Tripwire is enabled but has no account. + from shortcircuit.model.mapper_config import default_configs + w = _Window() + w.mapper_configs = default_configs() + w._startup_fetch() + w.btn_trip_get_clicked.assert_not_called() + + +def test_swap_button_is_in_the_window(qapp): + from shortcircuit.view.gui_main import Ui_MainWindow + + ui = Ui_MainWindow() + window = QtWidgets.QMainWindow() + ui.setupUi(window) + assert ui.pushButton_swap.text().startswith("Swap") diff --git a/src/shortcircuit/model/test_mapper_readiness.py b/src/shortcircuit/model/test_mapper_readiness.py new file mode 100644 index 0000000..2ed212a --- /dev/null +++ b/src/shortcircuit/model/test_mapper_readiness.py @@ -0,0 +1,68 @@ +# test_mapper_readiness.py +"""Mappers missing their credentials are never fetched. + +A fresh install's default config enables Tripwire with no account. Without +this guard the startup fetch would send a blank-credential login to the +Tripwire server (Tripwire logs in from its constructor) and greet every new +user with an error. +""" + +import os + +# The suite is headless: select the Qt platform plugin before Qt is imported. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from unittest import mock + +import pytest + +from shortcircuit.model.mapper_config import ( + TYPE_EVESCOUT, + TYPE_TRIPWIRE, + MapperConfig, + default_configs, + is_configured, +) + + +@pytest.mark.parametrize("cfg,expected", [ + (MapperConfig(type=TYPE_TRIPWIRE, name="t", url="https://tw"), False), + (MapperConfig(type=TYPE_TRIPWIRE, name="t", url="https://tw", user="u"), False), + (MapperConfig(type=TYPE_TRIPWIRE, name="t", user="u", password="p"), False), + (MapperConfig(type=TYPE_TRIPWIRE, name="t", url="https://tw", user="u", password="p"), True), + (MapperConfig(type=TYPE_EVESCOUT, name="e"), True), + (MapperConfig(type="mystery", name="m", url="x", user="u", password="p"), False), +]) +def test_is_configured(cfg, expected): + assert is_configured(cfg) is expected + + +def test_fresh_install_defaults_have_nothing_ready(): + # The default Tripwire is enabled but has no account; Eve Scout is off. + assert not any(c.enabled and is_configured(c) for c in default_configs()) + + +def test_setup_mappers_never_builds_an_unconfigured_mapper(): + from shortcircuit.model import navigation + from shortcircuit.model.navigation import Navigation + + app_obj = mock.Mock() + app_obj.mapper_configs = [ + MapperConfig(type=TYPE_TRIPWIRE, name="Blank Tripwire", url="https://tw"), + MapperConfig(type=TYPE_EVESCOUT, name="Eve Scout", enabled=True), + ] + nav = Navigation.__new__(Navigation) + nav.app_obj = app_obj + nav.mapper_registry = navigation.MapperRegistry() + + built = [] + builders = { + TYPE_TRIPWIRE: lambda cfg: built.append(cfg.name) or pytest.fail("built a blank Tripwire"), + TYPE_EVESCOUT: lambda cfg: built.append(cfg.name) or mock.Mock( + validate_config=lambda: (True, None), get_name=lambda: cfg.name), + } + with mock.patch.dict(navigation.MAPPER_BUILDERS, builders, clear=True): + nav.setup_mappers() + + assert built == ["Eve Scout"] + assert nav.mapper_registry.get_source_count() == 1 diff --git a/src/shortcircuit/view/gui_main.py b/src/shortcircuit/view/gui_main.py index 027ea23..e146733 100644 --- a/src/shortcircuit/view/gui_main.py +++ b/src/shortcircuit/view/gui_main.py @@ -3,7 +3,7 @@ ################################################################################ ## Form generated from reading UI file 'gui_main.ui' ## -## Created by: Qt User Interface Compiler version 6.11.2 +## Created by: Qt User Interface Compiler version 6.9.3 ## ## WARNING! All changes made in this file will be lost when recompiling UI file! ################################################################################ @@ -86,6 +86,11 @@ def setupUi(self, MainWindow): self.gridLayout.addWidget(self.pushButton_player_location, 1, 2, 1, 1) + self.pushButton_swap = QPushButton(self.groupBox) + self.pushButton_swap.setObjectName(u"pushButton_swap") + + self.gridLayout.addWidget(self.pushButton_swap, 2, 2, 1, 1) + self.pushButton_find_path = QPushButton(self.groupBox) self.pushButton_find_path.setObjectName(u"pushButton_find_path") @@ -497,6 +502,10 @@ def retranslateUi(self, MainWindow): self.lineEdit_destination.setPlaceholderText("") self.label_source.setText(QCoreApplication.translate("MainWindow", u"Source:", None)) self.pushButton_player_location.setText(QCoreApplication.translate("MainWindow", u"Player location", None)) +#if QT_CONFIG(tooltip) + self.pushButton_swap.setToolTip(QCoreApplication.translate("MainWindow", u"Swap source and destination", None)) +#endif // QT_CONFIG(tooltip) + self.pushButton_swap.setText(QCoreApplication.translate("MainWindow", u"Swap \u21c5", None)) self.pushButton_find_path.setText(QCoreApplication.translate("MainWindow", u"Find path", None)) self.label.setText(QCoreApplication.translate("MainWindow", u"Result:", None)) self.label_status.setText(QCoreApplication.translate("MainWindow", u"Path status", None)) @@ -534,3 +543,4 @@ def retranslateUi(self, MainWindow): self.pushButton_avoid_clear.setText(QCoreApplication.translate("MainWindow", u"Clear list", None)) self.pushButton_reset.setText(QCoreApplication.translate("MainWindow", u"Reset chain", None)) # retranslateUi +