From f24e1b74bc8e0dfaed25bfc59b63cf1809518c45 Mon Sep 17 00:00:00 2001 From: scottcorbin Date: Sun, 8 Nov 2026 18:17:45 -0800 Subject: [PATCH 1/2] Shorten launch-to-route: auto-location, a swap button, one startup fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A returning user's flow was: launch, log in, click Player location, click Fetch Mappers, then type a destination and Find path — two of those steps are things the app can do itself, and going there-and-back meant retyping both systems. - After a successful login the pilot's location is fetched and fills Source. It never overwrites a source the user already typed, and a failure (pilot offline, ESI error) is silent rather than a dialog — the manual Player location button still reports failures as before. - A Swap ⇅ button sits in the empty grid cell between Player location and Find path and exchanges Source and Destination. Added in gui_main.ui and regenerated with generate_gui.sh's command. - When mappers are configured, one fetch runs a second after launch. This is a single fetch per launch, not a recurring refresh, so no extra load on mapper servers while the app sits idle. Tests drive the handlers on a stand-in object (no settings, database or network) and check the generated window has the button. Co-Authored-By: Claude Opus 5.5 --- resources/ui/gui_main.ui | 10 ++ src/shortcircuit/app.py | 36 ++++++- src/shortcircuit/model/test_launch_flow.py | 113 +++++++++++++++++++++ src/shortcircuit/view/gui_main.py | 12 ++- 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 src/shortcircuit/model/test_launch_flow.py 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..bb5a18e 100644 --- a/src/shortcircuit/app.py +++ b/src/shortcircuit/app.py @@ -399,6 +399,12 @@ 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. + if any(cfg.enabled for cfg in self.mapper_configs): + QtCore.QTimer.singleShot(1000, self._startup_fetch) + # noinspection PyUnresolvedReferences def additional_gui_setup(self): # Additional GUI setup @@ -451,6 +457,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 +926,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 +943,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 +998,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 +1025,17 @@ 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 not self.worker_thread.isRunning(): + self.btn_trip_get_clicked() + @QtCore.Slot() def btn_trip_config_clicked(self): dialog = MappersDialog(self.mapper_configs, self.global_proxy, parent=self) diff --git a/src/shortcircuit/model/test_launch_flow.py b/src/shortcircuit/model/test_launch_flow.py new file mode 100644 index 0000000..f4a9e52 --- /dev/null +++ b/src/shortcircuit/model/test_launch_flow.py @@ -0,0 +1,113 @@ +# 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() + for name in ( + "login_handler", "location_handler", "btn_player_location_clicked", + "btn_swap_clicked", "_startup_fetch", + ): + 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_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/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 + From 70157f370cd0ecfa2dd9925e7b7e96b1246e3a2a Mon Sep 17 00:00:00 2001 From: scottcorbin Date: Sun, 8 Nov 2026 18:49:14 -0800 Subject: [PATCH 2/2] Skip the startup fetch for mappers that aren't set up A fresh install's default config enables Tripwire with no account, so the startup fetch added in this PR would build a Tripwire source on first launch. Tripwire logs in from its constructor, so that sends a blank-credential login to tripwire.eve-apps.com, a pointless request to someone else's server, and opens the app on an error. - mapper_config.is_configured() decides from the config alone whether a mapper has what it needs, mirroring each mapper's validate_config(). It can't build the source to ask, because building a Tripwire source is itself the login request. - The startup fetch is scheduled, and runs, only when at least one enabled mapper is set up. - Navigation.setup_mappers() skips mappers that aren't set up, so a manual fetch never sends a blank login either. Co-Authored-By: Claude Opus 5.5 --- src/shortcircuit/app.py | 13 +++- src/shortcircuit/model/mapper_config.py | 15 ++++ src/shortcircuit/model/navigation.py | 9 ++- src/shortcircuit/model/test_launch_flow.py | 13 +++- .../model/test_mapper_readiness.py | 68 +++++++++++++++++++ 5 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 src/shortcircuit/model/test_mapper_readiness.py diff --git a/src/shortcircuit/app.py b/src/shortcircuit/app.py index bb5a18e..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, @@ -401,8 +402,10 @@ def __init__(self, parent=None): # 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. - if any(cfg.enabled for cfg in self.mapper_configs): + # 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 @@ -1033,9 +1036,13 @@ def btn_swap_clicked(self): @QtCore.Slot() def _startup_fetch(self): - if not self.worker_thread.isRunning(): + 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 index f4a9e52..fcbe340 100644 --- a/src/shortcircuit/model/test_launch_flow.py +++ b/src/shortcircuit/model/test_launch_flow.py @@ -37,9 +37,11 @@ def __init__(self): 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", + "btn_swap_clicked", "_startup_fetch", "_mappers_ready", ): setattr(self, name, getattr(MainWindow, name).__get__(self)) @@ -104,6 +106,15 @@ def test_startup_fetch_runs_once_unless_a_fetch_is_already_running(qapp): 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 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