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
+