Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions resources/ui/gui_main.ui
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@
</property>
</widget>
</item>
<item row="2" column="2">
<widget class="QPushButton" name="pushButton_swap">
<property name="toolTip">
<string>Swap source and destination</string>
</property>
<property name="text">
<string>Swap ⇅</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QPushButton" name="pushButton_find_path">
<property name="text">
Expand Down
43 changes: 41 additions & 2 deletions src/shortcircuit/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
TYPE_EVESCOUT,
TYPE_TRIPWIRE,
default_configs,
is_configured,
load_configs,
migrate_legacy,
save_configs,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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)"
Expand Down Expand Up @@ -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()

Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions src/shortcircuit/model/mapper_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 7 additions & 2 deletions src/shortcircuit/model/navigation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
124 changes: 124 additions & 0 deletions src/shortcircuit/model/test_launch_flow.py
Original file line number Diff line number Diff line change
@@ -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")
68 changes: 68 additions & 0 deletions src/shortcircuit/model/test_mapper_readiness.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 11 additions & 1 deletion src/shortcircuit/view/gui_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!
################################################################################
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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