diff --git a/PortMaster/pylibs/harbourmaster/__init__.py b/PortMaster/pylibs/harbourmaster/__init__.py
index b1ad847b..c8bb9c8c 100644
--- a/PortMaster/pylibs/harbourmaster/__init__.py
+++ b/PortMaster/pylibs/harbourmaster/__init__.py
@@ -34,6 +34,7 @@
get_dict_list,
get_path_fs,
hash_file,
+ json_load_dict,
json_safe_load,
json_safe_loads,
load_pm_signature,
diff --git a/PortMaster/pylibs/harbourmaster/cli.py b/PortMaster/pylibs/harbourmaster/cli.py
index 4c028ee7..921116cf 100644
--- a/PortMaster/pylibs/harbourmaster/cli.py
+++ b/PortMaster/pylibs/harbourmaster/cli.py
@@ -25,6 +25,7 @@
LOG_FILE = harbourmaster.HM_TOOLS_DIR / "PortMaster" / "harbourmaster.txt"
+LOG_FILE_HANDLE = None
if LOG_FILE.parent.is_dir():
LOG_FILE_HANDLE = logger.add(LOG_FILE, level="DEBUG", backtrace=True, diagnose=True)
@@ -271,8 +272,9 @@ def do_portsjson(hm, argv):
port_info['date_added'], port_info['date_updated'] = (
ports_info.get('ports', {}).get(port_name, {}).get('date', (default_time, default_time)))
- del port_info['status']
- del port_info['files']
+ ## Only installed ports have these.
+ port_info.pop('status', None)
+ port_info.pop('files', None)
ports_json['ports'].append(port_info)
@@ -468,6 +470,11 @@ def do_fifo_control(hm, argv):
def do_device_info(hm, argv):
+ """
+ Device info for the website, every known device and CFW.
+
+ {command} device_info [filename]
+ """
import json
website_data = {}
@@ -606,7 +613,8 @@ def main(argv):
logger.remove(0) # For the default handler, it's actually '0'.
logger.add(sys.stderr, level="DEBUG")
- if config['no-log']:
+ ## logger.remove(None) would remove every handler.
+ if config['no-log'] and LOG_FILE_HANDLE is not None:
logger.remove(LOG_FILE_HANDLE)
LOG_FILE_HANDLE = None
diff --git a/PortMaster/pylibs/harbourmaster/console.py b/PortMaster/pylibs/harbourmaster/console.py
index 14694166..5a323c2b 100644
--- a/PortMaster/pylibs/harbourmaster/console.py
+++ b/PortMaster/pylibs/harbourmaster/console.py
@@ -31,7 +31,12 @@ def in_terminal():
"""
Are we in a terminal?
"""
- return os.isatty(sys.stdout.fileno()) and os.isatty(sys.stdin.fileno())
+ try:
+ return os.isatty(sys.stdout.fileno()) and os.isatty(sys.stdin.fileno())
+
+ except (AttributeError, OSError, ValueError):
+ ## No real file behind them (io.UnsupportedOperation is both of the last two).
+ return False
def do_cprint_output(file_handle):
diff --git a/PortMaster/pylibs/harbourmaster/harbour.py b/PortMaster/pylibs/harbourmaster/harbour.py
index 2af6a2f4..ca21c825 100644
--- a/PortMaster/pylibs/harbourmaster/harbour.py
+++ b/PortMaster/pylibs/harbourmaster/harbour.py
@@ -8,7 +8,7 @@
from loguru import logger
from .config import HM_PORTS_DIR, HM_SCRIPTS_DIR, HM_SOURCE_DEFAULTS, HM_TESTING, HM_TOOLS_DIR
from .hardware import device_info
-from .util import Callback, json_safe_load
+from .util import Callback, json_load_dict, json_safe_load
from .source import HM_SOURCE_APIS
from .platform import HM_PLATFORMS
from .hm_info import InfoMixin
@@ -125,11 +125,8 @@ def __init__(self, config, *, tools_dir=None, ports_dir=None, scripts_dir=None,
with self.callback.enable_messages():
self.callback.message(_("Loading..."))
- if not self.cfg_file.is_file():
- self.cfg_data = self.DEFAULT_CONFIG.copy()
- else:
- with open(self.cfg_file, 'r') as fh:
- self.cfg_data = json.load(fh)
+ ## Missing or broken (cut off by a power off while saving): start afresh.
+ self.cfg_data = json_load_dict(self.cfg_file, self.DEFAULT_CONFIG)
if self.cfg_data.get('first-run', True) or not self.cfg_dir.is_dir():
self.cfg_dir.mkdir(0o755, parents=True, exist_ok=True)
diff --git a/PortMaster/pylibs/harbourmaster/hm_info.py b/PortMaster/pylibs/harbourmaster/hm_info.py
index ac695634..489e253c 100644
--- a/PortMaster/pylibs/harbourmaster/hm_info.py
+++ b/PortMaster/pylibs/harbourmaster/hm_info.py
@@ -70,7 +70,9 @@ def load_info_fetch_remote(self, local_file, remote_url, remote_size, sha_source
if sha_local != sha_source:
self.callback.message(_("Download validation failed."))
logger.error(f"File validation failed {sha_local} vs {sha_source}")
+ ## Keep the old file: this went on to copy the deleted download over it.
temp_file.unlink()
+ return False
if temp_file != local_file:
shutil.copy2(temp_file, local_file)
@@ -107,9 +109,11 @@ def load_info(self, force_load=False):
with open(self.runtimes_file, 'r') as fh:
self.runtimes_info = json_safe_load(fh)
- self.list_runtimes()
+ ## A cut-off or corrupt runtimes.json crashed every start.
+ if isinstance(self.runtimes_info, dict):
+ self.list_runtimes()
- if self.runtimes_info is None:
+ if not isinstance(self.runtimes_info, dict):
self.runtimes_info = {}
# These are the base files, and empty versions that are safe.
diff --git a/PortMaster/pylibs/harbourmaster/hm_install.py b/PortMaster/pylibs/harbourmaster/hm_install.py
index dda43cd3..fe999e06 100644
--- a/PortMaster/pylibs/harbourmaster/hm_install.py
+++ b/PortMaster/pylibs/harbourmaster/hm_install.py
@@ -12,7 +12,7 @@
from .console import cprint
from .config import HM_ACCEPTABLE_NON_BASH_TOP_LEVEL_FILES
from .util import HarbourException, add_dict_list_unique, add_list_unique, add_pm_signature, get_dict_list, get_path_fs, hash_file, name_cleaner, runtime_nicename
-from .info import port_info_load, port_info_merge
+from .info import is_safe_port_item, port_info_load, port_info_merge
from .source import raw_download
from .captain import check_port
@@ -584,11 +584,15 @@ def uninstall_port(self, port_name):
ports_dir = self.ports_dir
+ for item in all_port_items:
+ if not is_safe_port_item(item):
+ logger.error(f"Not removing {item!r} for {port_name}: it is outside the ports folder.")
+
uninstall_items = [
item
for item in all_port_items
# Only delete files/scripts with only 1 owner.
- if len(get_dict_list(all_items, item)) == 1]
+ if len(get_dict_list(all_items, item)) == 1 and is_safe_port_item(item)]
self.platform.port_uninstall(port_name, port_info, all_port_items)
diff --git a/PortMaster/pylibs/harbourmaster/hm_ports.py b/PortMaster/pylibs/harbourmaster/hm_ports.py
index ddc4dd98..f799a314 100644
--- a/PortMaster/pylibs/harbourmaster/hm_ports.py
+++ b/PortMaster/pylibs/harbourmaster/hm_ports.py
@@ -46,7 +46,8 @@ def _get_pm_signature(self, file_name):
if len(port_owners) > 0:
add_pm_signature(file_name, [port_owners[0], other_name])
- return (other_name, file_name.name, port_owners[0])
+ ## (file_name, original_file_name, port_name), as the other returns.
+ return (file_name.name, other_name, port_owners[0])
return (file_name.name, file_name.name, None)
@@ -74,9 +75,10 @@ def _load_port_info(self, port_file):
logger.error(f"Unable to load {port_file}, missing 'name' and 'items' keys.")
return None
+ ## ports_info.json's items are "script.sh": "port.zip" (a list if shared), in the script's own case.
for item in port_info['items']:
- port_temp = ports_info['items'].get(item.casefold(), None)
- if isinstance(port_temp, str):
+ port_owners = get_dict_list(ports_info['items'], item)
+ if len(port_owners) > 0:
break
else:
# Couldn't find the port.
@@ -84,7 +86,7 @@ def _load_port_info(self, port_file):
return None
changed = True
- port_info['name'] = name_cleaner(port_temp[0])
+ port_info['name'] = name_cleaner(port_owners[0])
# Force the port_info['name'] to be lowercase/casefolded.
if port_info['name'] != name_cleaner(port_info['name']):
@@ -179,7 +181,9 @@ def load_ports(self):
all_ports = {}
ports_files = {}
+ ## original name -> renamed file, and back.
file_renames = {}
+ file_originals = {}
ports_info = self.ports_info()
self._PORT_INFO_CACHE.clear()
@@ -374,6 +378,7 @@ def load_ports(self):
if pm_signature[0] != pm_signature[1]:
# We atleast know the file is renamed.
file_renames[pm_signature[1]] = pm_signature[0]
+ file_originals[pm_signature[0]] = pm_signature[1]
if pm_signature[2] is None:
# Unknown port?
@@ -407,7 +412,7 @@ def load_ports(self):
elif len(port_owners) == 0:
if unknown_file.casefold().endswith('.sh'):
- re_name = file_renames.get(unknown_file, None)
+ re_name = file_originals.get(unknown_file, None)
if re_name is not None:
port_owners = get_dict_list(ports_info['items'], re_name)
@@ -511,6 +516,10 @@ def load_ports(self):
file_names = get_dict_list(port_info['files'], port_file)
for file_name in list(file_names):
+ ## A new port's port.json is written below.
+ if port_file == 'port.json' and port_name in ports_files:
+ continue
+
if not self._ports_dir_exists(file_name):
remove_dict_list(port_info['files'], port_file, file_name)
port_info['changed'] = True
diff --git a/PortMaster/pylibs/harbourmaster/hm_runtimes.py b/PortMaster/pylibs/harbourmaster/hm_runtimes.py
index ff2b4fd6..b50d20f9 100644
--- a/PortMaster/pylibs/harbourmaster/hm_runtimes.py
+++ b/PortMaster/pylibs/harbourmaster/hm_runtimes.py
@@ -25,6 +25,11 @@ def list_runtimes(self):
runtime_data['remote']['aarch64'] = temp
changed = True
+ local_info = runtime_data.get('local')
+ if not isinstance(local_info, dict) or (local_info.get('status') == 'Not Installed' and runtime_file.is_file()):
+ ## Never checked, or copied in since (by hand, autoinstall): check it now.
+ runtime_data.pop('local', None)
+
if 'local' not in runtime_data:
changed = True
runtime_file = (self.libs_dir / runtime_name)
@@ -66,7 +71,8 @@ def list_runtimes(self):
}
else:
- if not runtime_file.is_file():
+ ## Only when it changes: this runs on every start.
+ if not runtime_file.is_file() and runtime_data['local'].get('status') != 'Not Installed':
changed = True
runtime_data['local'] = {
'status': 'Not Installed',
diff --git a/PortMaster/pylibs/harbourmaster/info.py b/PortMaster/pylibs/harbourmaster/info.py
index 0d90e94c..ab39b4d0 100644
--- a/PortMaster/pylibs/harbourmaster/info.py
+++ b/PortMaster/pylibs/harbourmaster/info.py
@@ -95,6 +95,20 @@ def _clean_attr_types(attr, source_name):
attr[key] = bool(attr.get(key, False))
+def is_safe_port_item(item):
+ """
+ A port's item (a script or folder in the ports folder) is a plain relative name:
+ uninstalling deletes it, so `..` in it would delete things outside the ports folder.
+ """
+ if not isinstance(item, str) or item.strip() in ("", ".", "./"):
+ return False
+
+ if item.startswith(('/', '\\')):
+ return False
+
+ return not any(part == '..' for part in item.replace('\\', '/').split('/'))
+
+
def port_info_load(raw_info, source_name=None, do_default=False):
if isinstance(raw_info, pathlib.PurePath):
source_name = str(raw_info)
@@ -251,58 +265,22 @@ def port_info_load(raw_info, source_name=None, do_default=False):
if attr in info:
port_info[attr] = info[attr]
- if isinstance(port_info['items'], list):
- i = 0
- while i < len(port_info['items']):
- item = port_info['items'][i]
- if item.startswith('/'):
- logger.error(f"port_info['items'] contains bad name {item!r}")
- del port_info['items'][i]
- continue
-
- if item.startswith('../'):
- logger.error(f"port_info['items'] contains bad name {item!r}")
- del port_info['items'][i]
- continue
-
- if '/../' in item:
- logger.error(f"port_info['items'] contains bad name {item!r}")
- del port_info['items'][i]
- continue
-
- if item == "":
- logger.error(f"port_info['items'] contains bad name {item!r}")
- del port_info['items'][i]
-
- i += 1
-
- if isinstance(port_info['items_opt'], list):
- i = 0
- while i < len(port_info['items_opt']):
- item = port_info['items_opt'][i]
- if item.startswith('/'):
- logger.error(f"port_info['items_opt'] contains bad name {item}")
- del port_info['items_opt'][i]
- continue
-
- if item.startswith('../'):
- logger.error(f"port_info['items_opt'] contains bad name {item}")
- del port_info['items_opt'][i]
- continue
-
- if '/../' in item:
- logger.error(f"port_info['items_opt'] contains bad name {item}")
- del port_info['items_opt'][i]
- continue
-
- if item == "":
- logger.error(f"port_info['items'] contains bad name {item!r}")
- del port_info['items_opt'][i]
-
- i += 1
-
- if port_info['items_opt'] == []:
- port_info['items_opt'] = None
+ if port_info['items_opt'] is not None and not isinstance(port_info['items_opt'], list):
+ logger.warning(f"port_info['items_opt'] is not a list in {source_name!r}: {port_info['items_opt']!r}")
+ port_info['items_opt'] = None
+
+ for key in ('items', 'items_opt'):
+ if not isinstance(port_info[key], list):
+ continue
+
+ for item in port_info[key]:
+ if not is_safe_port_item(item):
+ logger.error(f"port_info[{key!r}] contains bad name {item!r}")
+
+ port_info[key] = [item for item in port_info[key] if is_safe_port_item(item)]
+
+ if port_info['items_opt'] == []:
+ port_info['items_opt'] = None
if isinstance(port_info['attr'].get('genres', None), list):
genres = port_info['attr']['genres']
diff --git a/PortMaster/pylibs/harbourmaster/platform/base.py b/PortMaster/pylibs/harbourmaster/platform/base.py
index f99e4da5..e89a36c7 100644
--- a/PortMaster/pylibs/harbourmaster/platform/base.py
+++ b/PortMaster/pylibs/harbourmaster/platform/base.py
@@ -68,10 +68,24 @@ def gamelist_backup(self):
gamelist_xml = self.gamelist_file()
- if gamelist_xml in (None, SPECIAL_GAMELIST_CODE):
+ if gamelist_xml is None:
yield gamelist_xml
return
+ if gamelist_xml is SPECIAL_GAMELIST_CODE:
+ ## The CFW's own catalogue (muOS, TrimUI): nothing to back up, but a failure
+ ## mustn't fail the install either.
+ try:
+ yield gamelist_xml
+
+ except BaseException as err:
+ if isinstance(err, (CancelEvent, KeyboardInterrupt, SystemExit, GeneratorExit)):
+ raise
+
+ logger.exception(f"Updating the frontend's catalogue failed: {err}")
+
+ return
+
gamelist_bak = gamelist_xml.with_name(gamelist_xml.name + '.bak')
broken = False
diff --git a/PortMaster/pylibs/harbourmaster/platform/muos.py b/PortMaster/pylibs/harbourmaster/platform/muos.py
index feee4db1..e3765bfb 100644
--- a/PortMaster/pylibs/harbourmaster/platform/muos.py
+++ b/PortMaster/pylibs/harbourmaster/platform/muos.py
@@ -42,6 +42,13 @@ def gamelist_add(self, gameinfo_file):
INFO_PREVIEW_DIR = INFO_CATALOG / "preview"
INFO_TEXT_DIR = INFO_CATALOG / "text"
+ ## Not this muOS's layout: leave it be.
+ if not INFO_CATALOG.is_dir():
+ return
+
+ for info_dir in (INFO_BOX_DIR, INFO_PREVIEW_DIR, INFO_TEXT_DIR):
+ info_dir.mkdir(exist_ok=True)
+
with self.gamelist_backup() as gamelist_xml:
if gamelist_xml is None:
return
diff --git a/PortMaster/pylibs/harbourmaster/platform/retrodeck.py b/PortMaster/pylibs/harbourmaster/platform/retrodeck.py
index 2466e966..fc7da2d7 100644
--- a/PortMaster/pylibs/harbourmaster/platform/retrodeck.py
+++ b/PortMaster/pylibs/harbourmaster/platform/retrodeck.py
@@ -82,6 +82,10 @@ def get_rdconfig(self):
if not rdconfig_file.is_file():
rdconfig_file=(Path.home() / ".var/app/net.retrodeck.retrodeck/config/retrodeck/retrodeck.cfg")
+ if not rdconfig_file.is_file():
+ logger.error("Unable to find RetroDECK's retrodeck.json or retrodeck.cfg.")
+ return None
+
with open(rdconfig_file, 'r') as fh:
for line in fh:
line = line.strip()
diff --git a/PortMaster/pylibs/harbourmaster/source/portmaster.py b/PortMaster/pylibs/harbourmaster/source/portmaster.py
index 3ff1e8d7..32f023df 100644
--- a/PortMaster/pylibs/harbourmaster/source/portmaster.py
+++ b/PortMaster/pylibs/harbourmaster/source/portmaster.py
@@ -41,6 +41,16 @@ def update(self):
if self.hm.callback is not None:
self.hm.callback.message(" - {}".format(_("Updating")))
+ ## Always fetched, even if it was at start: a release may have been made since.
+ if self.hm.callback is not None:
+ self.hm.callback.message(" - {}".format(_("Fetching latest info")))
+
+ data = net.fetch_json(self._config['url'])
+ if data is None:
+ ## No network yet, or the download failed: use the cached ports until the next try.
+ self.load()
+ return
+
changed = False
# Scrap the rest
self._data = {}
@@ -49,17 +59,6 @@ def update(self):
self.utils = []
self.images = {}
- if self._did_update:
- self.hm.callback.message(" - {}".format(_("Up to date already")))
- return
-
- if self.hm.callback is not None:
- self.hm.callback.message(" - {}".format(_("Fetching latest info")))
-
- data = net.fetch_json(self._config['url'])
- if data is None:
- return
-
## Load data from the assets.
for key, asset in data['ports'].items():
asset = port_info_load(asset)
@@ -132,7 +131,6 @@ def update(self):
self._config['last_checked'] = datetime.datetime.now().isoformat()
self.save()
- self._did_update = True
self.hm.callback.message(" - {}".format(_("Done.")))
## Port images
diff --git a/PortMaster/pylibs/harbourmaster/util/__init__.py b/PortMaster/pylibs/harbourmaster/util/__init__.py
index 1e1f48c1..df900aa9 100644
--- a/PortMaster/pylibs/harbourmaster/util/__init__.py
+++ b/PortMaster/pylibs/harbourmaster/util/__init__.py
@@ -39,6 +39,7 @@
from .data import (
json_safe_loads,
json_safe_load,
+ json_load_dict,
add_list_unique,
add_dict_list_unique,
get_dict_list,
@@ -70,6 +71,7 @@
'get_path_fs',
'hash_file',
'hash_file_sha',
+ 'json_load_dict',
'json_safe_load',
'json_safe_loads',
'list_to_tuple',
diff --git a/PortMaster/pylibs/harbourmaster/util/data.py b/PortMaster/pylibs/harbourmaster/util/data.py
index 0025ef2a..36f1c179 100644
--- a/PortMaster/pylibs/harbourmaster/util/data.py
+++ b/PortMaster/pylibs/harbourmaster/util/data.py
@@ -23,6 +23,32 @@ def json_safe_load(*args):
except json.JSONDecodeError as err:
logger.error(f"Unable to load json_data {err.doc}:{err.pos}:{err.lineno}:{err.colno}")
return None
+ except ValueError as err:
+ ## Not even text (UnicodeDecodeError): a file cut off or scrambled on the SD card.
+ logger.error(f"Unable to load json_data: {err}")
+ return None
+
+
+def json_load_dict(file_name, default=None):
+ """
+ A json file that holds an object (a config file), or a copy of `default` ({})
+ when it is missing, unreadable or not an object. Devices get switched off while
+ saving: a broken config.json must not stop PortMaster from starting.
+ """
+ try:
+ with open(str(file_name), 'r') as fh:
+ data = json_safe_load(fh)
+
+ except OSError:
+ data = None
+
+ if not isinstance(data, dict):
+ if data is not None:
+ logger.error(f"Unable to load {file_name}: not a json object.")
+
+ return dict(default or {})
+
+ return data
def add_list_unique(base_list, value):
diff --git a/PortMaster/pylibs/harbourmaster/util/net.py b/PortMaster/pylibs/harbourmaster/util/net.py
index 1c8f8ca4..e2a5efa1 100644
--- a/PortMaster/pylibs/harbourmaster/util/net.py
+++ b/PortMaster/pylibs/harbourmaster/util/net.py
@@ -19,7 +19,8 @@ def fetch(url):
logger.error(f"Failed to download {url!r}: {r.status_code}")
return None
- except requests.exceptions.ConnectionError as err:
+ except requests.RequestException as err:
+ ## Timeouts too: a slow first-boot network used to crash HarbourMaster's startup.
logger.error(f"Failed to download {url!r}: {err}")
return None
@@ -39,7 +40,13 @@ def fetch_json(url):
if r is None:
return None
- return r.json()
+ try:
+ return r.json()
+
+ except ValueError as err:
+ ## A captive portal's login page, or a cut-off download.
+ logger.error(f"Bad json from {url!r}: {err}")
+ return None
def fetch_text(url):
diff --git a/PortMaster/pylibs/harbourmaster/util/text.py b/PortMaster/pylibs/harbourmaster/util/text.py
index c909776f..4c9224f1 100644
--- a/PortMaster/pylibs/harbourmaster/util/text.py
+++ b/PortMaster/pylibs/harbourmaster/util/text.py
@@ -17,7 +17,7 @@ def nice_size(size):
suffixes = ('B', 'KB', 'MB', 'GB', 'TB')
for suffix in suffixes:
- if size < 768:
+ if size < 768 or suffix == suffixes[-1]:
break
size /= 1024
@@ -104,6 +104,15 @@ def runtime_nicename(runtime):
runtime_nicename(_runtime)
for _runtime in runtime])
+ try:
+ return _runtime_nicename(runtime)
+
+ except IndexError:
+ ## Not named the usual way (eg: "mono.squashfs" from a hand-written port.json).
+ return runtime
+
+
+def _runtime_nicename(runtime):
if runtime.startswith("frt"):
return ("Godot/FRT {version}").format(version=runtime.split('_', 1)[1].rsplit('.', 1)[0])
@@ -134,14 +143,32 @@ def runtime_nicename(runtime):
return runtime
+def _as_datetime(value):
+ if isinstance(value, str):
+ value = datetime.datetime.fromisoformat(value)
+
+ if not isinstance(value, datetime.datetime):
+ raise TypeError(f"not a time: {value!r}")
+
+ if value.tzinfo is not None:
+ ## Local time without the zone, as datetime.now() is.
+ value = value.astimezone().replace(tzinfo=None)
+
+ return value
+
+
def datetime_compare(time_a, time_b=None):
- if isinstance(time_a, str):
- time_a = datetime.datetime.fromisoformat(time_a)
+ """
+ Seconds from time_a to time_b (now). A time that can't be read (a hand-edited
+ or broken config) counts as long ago, so whatever was waiting on it happens.
+ """
+ try:
+ time_a = _as_datetime(time_a)
+ time_b = datetime.datetime.now() if time_b is None else _as_datetime(time_b)
- if time_b is None:
- time_b = datetime.datetime.now()
- elif isinstance(time_b, str):
- time_b = datetime.datetime.fromisoformat(time_b)
+ except (TypeError, ValueError) as err:
+ logger.warning(f"datetime_compare({time_a!r}, {time_b!r}): {err}")
+ return float('inf')
return (time_b - time_a).total_seconds()
diff --git a/PortMaster/pylibs/pugwash/app.py b/PortMaster/pylibs/pugwash/app.py
index 64b907bc..37882545 100644
--- a/PortMaster/pylibs/pugwash/app.py
+++ b/PortMaster/pylibs/pugwash/app.py
@@ -257,8 +257,7 @@ def get_config(self):
if self.hm is None:
if cfg_file.is_file():
- with open(cfg_file, 'r') as fh:
- cfg_data = json.load(fh)
+ cfg_data = harbourmaster.json_load_dict(cfg_file)
else:
cfg_data = self.hm.cfg_data
diff --git a/PortMaster/pylibs/pugwash/app_fifo.py b/PortMaster/pylibs/pugwash/app_fifo.py
index 2a55bbb6..ce464c3d 100644
--- a/PortMaster/pylibs/pugwash/app_fifo.py
+++ b/PortMaster/pylibs/pugwash/app_fifo.py
@@ -41,7 +41,7 @@ def fifo_reg_clear_info(self, fifo_config, args):
fifo_config['done-file'].write_text("FAIL")
return
- reg_name, key_name, value = args[:2]
+ reg_name, key_name = args[:2]
register = fifo_config["register"].setdefault(reg_name, {})
if key_name in register:
del register[key_name]
@@ -193,7 +193,7 @@ def fifo_progress(self, fifo_config, args):
fmt = None
if len(args) > 3 and args[3] in ('data', '%'):
- total = args[3]
+ fmt = args[3]
if len(args) > 2 and args[2].isnumeric():
total = int(args[2])
@@ -393,7 +393,7 @@ def do_fifo_control(self, config, argv):
printf "message_box\1with_false\1This is a message you might want to display." | sudo tee /dev/shm/portmaster/hm_input > /dev/null
"""
- if len(argv[1]) < 2:
+ if len(argv) < 2:
return 0
logger.info("-- Beginning Fifo Control --")
@@ -438,7 +438,14 @@ def do_fifo_control(self, config, argv):
return 0
if args[0].lower() in self.fifo_commands:
- self.fifo_commands[args[0].lower()](self, fifo_config, args[1:])
+ ## Always answer: the shell script waits until the done file changes,
+ ## and a command that raises would end fifo_control on WAIT.
+ try:
+ self.fifo_commands[args[0].lower()](self, fifo_config, args[1:])
+
+ except Exception:
+ logger.exception(f"fifo: {args} failed")
+ done_file.write_text("FAIL")
else:
logger.warning(f"fifo: unknown command {args[0]}")
diff --git a/PortMaster/pylibs/pugwash/main.py b/PortMaster/pylibs/pugwash/main.py
index 89b291d7..80b4eceb 100644
--- a/PortMaster/pylibs/pugwash/main.py
+++ b/PortMaster/pylibs/pugwash/main.py
@@ -78,10 +78,12 @@ def main(argv):
logger.add(sys.stderr, level="SUCCESS")
## Once we reach here we can just reduce it to INFO level.
- logger.remove(LOG_FILE_HANDLE)
- LOG_FILE_HANDLE = logger.add(LOG_FILE, level="INFO", backtrace=True, diagnose=True)
+ ## (logger.remove(None) would remove every handler, stderr's too.)
+ if LOG_FILE_HANDLE is not None:
+ logger.remove(LOG_FILE_HANDLE)
+ LOG_FILE_HANDLE = logger.add(LOG_FILE, level="INFO", backtrace=True, diagnose=True)
- if config['no-log']:
+ if config['no-log'] and LOG_FILE_HANDLE is not None:
logger.remove(LOG_FILE_HANDLE)
LOG_FILE_HANDLE = None
diff --git a/PortMaster/pylibs/pugwash/scenes/language.py b/PortMaster/pylibs/pugwash/scenes/language.py
index 6dc678ad..332a3c5b 100644
--- a/PortMaster/pylibs/pugwash/scenes/language.py
+++ b/PortMaster/pylibs/pugwash/scenes/language.py
@@ -33,7 +33,12 @@ def do_update(self, events):
super().do_update(events)
if events.was_pressed('A'):
- selected_option, selected_parameter = self.tags['option_list'].selected_option()
+ selected = self.tags['option_list'].selected_option()
+ if not isinstance(selected, tuple):
+ ## The blank row (the d-pad skips it, a theme's list might not).
+ return True
+
+ selected_option, selected_parameter = selected
self.button_activate()
@@ -47,7 +52,8 @@ def do_update(self, events):
if self.gui.message_box(_("Do you want to change language?\n\nYou will have to restart for it to take affect."), want_cancel=True):
from pugwash import lang
if selected_parameter == lang.DEFAULT_LANG:
- del self.gui.hm.cfg_data['language']
+ ## Not set when the language came from the system.
+ self.gui.hm.cfg_data.pop('language', None)
else:
self.gui.hm.cfg_data['language'] = selected_parameter
diff --git a/PortMaster/pylibs/pugwash/scenes/options.py b/PortMaster/pylibs/pugwash/scenes/options.py
index 47c7ac95..0f4570a7 100644
--- a/PortMaster/pylibs/pugwash/scenes/options.py
+++ b/PortMaster/pylibs/pugwash/scenes/options.py
@@ -446,7 +446,8 @@ def do_update(self, events):
if selected_option == 'delete-config':
self.gui.events.running = False
- shutil.rmtree(harbourmaster.HM_TOOLS_DIR / "PortMaster" / "config")
+ ## Already gone (deleted by hand) is fine too.
+ shutil.rmtree(str(self.gui.hm.cfg_dir), ignore_errors=True)
if not harbourmaster.HM_TESTING:
reboot_file = (harbourmaster.HM_TOOLS_DIR / "PortMaster" / ".pugwash-reboot")
@@ -456,7 +457,7 @@ def do_update(self, events):
return True
if selected_option == 'delete-runtimes':
- runtimes = list((harbourmaster.HM_TOOLS_DIR / "PortMaster" / "libs").glob("*.squashfs"))
+ runtimes = list(self.gui.hm.libs_dir.glob("*.squashfs"))
if len(runtimes) == 0:
self.gui.message_box("No runtimes found.")
return True
diff --git a/PortMaster/pylibs/pugwash/scenes/themes.py b/PortMaster/pylibs/pugwash/scenes/themes.py
index a69ca53d..1b279e62 100644
--- a/PortMaster/pylibs/pugwash/scenes/themes.py
+++ b/PortMaster/pylibs/pugwash/scenes/themes.py
@@ -133,7 +133,12 @@ def do_update(self, events):
super().do_update(events)
if events.was_pressed('A'):
- selected_option, selected_parameter = self.tags['option_list'].selected_option()
+ selected = self.tags['option_list'].selected_option()
+ if not isinstance(selected, tuple):
+ ## The blank row (the d-pad skips it, a theme's list might not).
+ return True
+
+ selected_option, selected_parameter = selected
self.button_activate()
diff --git a/PortMaster/pylibs/pugwash/sdl/events.py b/PortMaster/pylibs/pugwash/sdl/events.py
index 0b331bd4..1f9282d7 100644
--- a/PortMaster/pylibs/pugwash/sdl/events.py
+++ b/PortMaster/pylibs/pugwash/sdl/events.py
@@ -109,13 +109,16 @@ def __init__(self, gui):
key: False
for key in self.BUTTON_MAP.values()}
+ ## Every button by name: the sticks' and triggers' too, and the keyboard's.
self.buttons.update({
key: False
- for key in self.AXIS_TO_BUTTON.keys()})
+ for keys in self.AXIS_TO_BUTTON.values()
+ for key in keys
+ if key is not None})
self.buttons.update({
key: False
- for key in self.KEY_MAP.keys()})
+ for key in self.KEY_MAP.values()})
self.axis = {
key: 0.0
diff --git a/PortMaster/pylibs/pugwash/sdl/helpers.py b/PortMaster/pylibs/pugwash/sdl/helpers.py
index 8b1db64e..54a33874 100644
--- a/PortMaster/pylibs/pugwash/sdl/helpers.py
+++ b/PortMaster/pylibs/pugwash/sdl/helpers.py
@@ -152,8 +152,14 @@ def hex_color_decode(hex_code):
if len(hex_code) not in (6, 8):
print(f'bad hex_code: {hex_code}')
- return [255, 255, 255]
+ return (255, 255, 255)
- return [
- int(hex_code[i:i+2], 16)
- for i in range(0, len(hex_code), 2)]
+ ## A tuple: it's cached, so nobody may change it. A typo ("#ggg") is white, as a bad length is.
+ try:
+ return tuple(
+ int(hex_code[i:i+2], 16)
+ for i in range(0, len(hex_code), 2))
+
+ except ValueError:
+ print(f'bad hex_code: {hex_code}')
+ return (255, 255, 255)
diff --git a/PortMaster/pylibs/pugwash/theme.py b/PortMaster/pylibs/pugwash/theme.py
index 4c8aff52..4eb9107a 100644
--- a/PortMaster/pylibs/pugwash/theme.py
+++ b/PortMaster/pylibs/pugwash/theme.py
@@ -349,8 +349,12 @@ def __init__(self, theme_dir):
if not self.theme_file.is_file():
raise ValueError(f"{self.theme_file} not found.")
+ ## Cut off (a download, a full SD card) or hand edited: callers skip it.
with open(self.theme_file, 'r') as fh:
- self.theme_data = json.load(fh)
+ self.theme_data = harbourmaster.json_safe_load(fh)
+
+ if not isinstance(self.theme_data, dict):
+ raise ValueError(f"{self.theme_file} is broken.")
self.__screenshot = None
@@ -417,12 +421,8 @@ def __init__(self, gui, engine):
self.engine = engine
config_file = self.gui.hm.cfg_dir / "themes.json"
- if config_file.is_file():
- with open(config_file, 'r') as fh:
- config_data = json.load(fh)
-
- else:
- config_data = self.DEFAULT_DATA.copy()
+ ## Broken or missing keys: what's missing comes from the defaults.
+ config_data = {**self.DEFAULT_DATA, **harbourmaster.json_load_dict(config_file)}
self._themes = None
@@ -567,8 +567,7 @@ def get_pm_config(self):
if self.gui.hm is None:
if cfg_file.is_file():
- with open(cfg_file, 'r') as fh:
- cfg_data = json.load(fh)
+ cfg_data = harbourmaster.json_load_dict(cfg_file)
else:
cfg_data = self.gui.hm.cfg_data
@@ -592,8 +591,22 @@ def get_current_theme(self):
logger.error(f"Unable to find theme '{cfg_data['theme']}', setting to 'default_theme'")
cfg_data["theme"] = "default_theme"
+ elif cfg_data["theme"] != "default_theme" and not self._theme_loads(cfg_data["theme"]):
+ ## Otherwise PortMaster wouldn't start again until the theme was deleted by hand.
+ logger.error(f"Unable to load theme '{cfg_data['theme']}', setting to 'default_theme'")
+ cfg_data["theme"] = "default_theme"
+
return cfg_data["theme"]
+ def _theme_loads(self, theme_name):
+ try:
+ self.get_theme(theme_name)
+
+ except ValueError:
+ return False
+
+ return True
+
def get_current_theme_scheme(self):
"""
Returns the colour scheme of the current theme
@@ -655,7 +668,12 @@ def get_themes_list(self, download_themes=None):
## Load local ones
for theme_file in (cfg_dir / "themes").glob("*/theme.json"):
theme_name = theme_file.parent.name
- theme_info = self.get_theme_info(theme_name)
+ try:
+ theme_info = self.get_theme_info(theme_name)
+
+ except ValueError as err:
+ logger.error(f"Skipping theme {theme_name}: {err}")
+ continue
themes[theme_name] = theme_info
@@ -696,6 +714,20 @@ def get_theme_schemes_list(self, theme_name=None):
return theme.schemes
def gui_init(self):
- theme = self.get_theme(self.get_current_theme())
+ theme_name = self.get_current_theme()
scheme = self.get_current_theme_scheme()
- return theme.gui_init(self.gui, scheme)
+
+ try:
+ return self.get_theme(theme_name).gui_init(self.gui, scheme)
+
+ except Exception:
+ ## A custom theme that doesn't load (eg: no option_menu section) would stop
+ ## PortMaster starting every time: use the default theme. Not when one is
+ ## forced (the theme editor shows what's wrong with it).
+ if self.force_theme is not None or theme_name == "default_theme":
+ raise
+
+ logger.exception(f"Unable to load theme {theme_name!r}, using the default theme.")
+ self.get_pm_config()["theme"] = "default_theme"
+ self.gui.resources.remove_path(self.get_theme_dir(theme_name))
+ return self.get_theme("default_theme").gui_init(self.gui, None)
diff --git a/PortMaster/pylibs/pugwash/update.py b/PortMaster/pylibs/pugwash/update.py
index 0d8d0acc..c919bfd1 100644
--- a/PortMaster/pylibs/pugwash/update.py
+++ b/PortMaster/pylibs/pugwash/update.py
@@ -18,8 +18,7 @@ def portmaster_check_update(pm, config, temp_dir):
cfg_data = {}
if cfg_file.is_file():
- with open(cfg_file, 'r') as fh:
- cfg_data = json.load(fh)
+ cfg_data = harbourmaster.json_load_dict(cfg_file)
update_checked = cfg_data.get('update_checked', None)
@@ -47,7 +46,8 @@ def portmaster_check_update(pm, config, temp_dir):
logger.error(f'Unable to download {PORTMASTER_RELEASE_URL}version.json')
return False
- if release_channel not in release_info:
+ channel_info = release_info.get(release_channel) if isinstance(release_info, dict) else None
+ if not isinstance(channel_info, dict) or not all(isinstance(channel_info.get(key), str) for key in ('version', 'url', 'md5')):
logger.error(f'Unable to find {release_channel} in {release_info!r}')
# Whelp, we tried.
return False
diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py
new file mode 100644
index 00000000..baa55b9e
--- /dev/null
+++ b/tests/test_cli_commands.py
@@ -0,0 +1,488 @@
+# SPDX-License-Identifier: MIT
+#
+# harbourmaster.cli's commands run in-process (test_cli.py runs the script), on
+# HarbourMasters made by the fixtures.
+
+import json
+
+import pytest
+
+import harbourmaster
+from harbourmaster import cli, console
+
+from conftest import basic_port_files, write_port_zip
+
+
+@pytest.fixture(autouse=True)
+def plain_output():
+ console.do_color(False)
+ yield
+ console.do_cprint_output(None)
+
+
+@pytest.fixture
+def cli_hm(online_hm):
+ ## The commands read hm.callback.config, as ConsoleCallback has it.
+ online_hm.callback.config = {'quiet': True}
+ return online_hm
+
+
+################################################################################
+## ConsoleCallback
+@pytest.mark.parametrize("amount, total, fmt, expected", [
+ (1024, None, 'data', "1.00 KB"),
+ (1024, 2048, 'data', "1.00 KB / 2.00 KB"),
+ (50, None, '%', "50.00 %"),
+ (250, None, '%', "100.00 %"),
+ (1, 4, '%', "25.00 %"),
+ (8, 4, '%', "100.00 %"),
+ (3, None, None, "3"),
+ (3, 9, None, "3 / 9"),
+ ])
+def test_console_callback_format_progress(amount, total, fmt, expected):
+ assert cli.ConsoleCallback({'quiet': False}).format_progress(amount, total, fmt) == expected
+
+
+def test_console_callback_progress(capsys):
+ ccb = cli.ConsoleCallback({'quiet': False})
+
+ ## Only data progress is drawn.
+ ccb.progress("Counting", 1, 2, None)
+ assert capsys.readouterr().out == ""
+
+ ccb.progress("Downloading", 10, 40, 'data')
+ ccb.progress("Downloading", 20, 40, 'data')
+ out = capsys.readouterr().out
+ assert out.count(" - Downloading") == 1
+ assert "[" + "|" * 20 + " " * 20 + "]" in out
+
+ ccb.progress("Downloading", 5, None, 'data')
+ assert "?" * 40 in capsys.readouterr().out
+
+ ## None resets the title, so it is printed again.
+ ccb.progress(None, 0)
+ assert ccb.last_message is None
+ ccb.progress("Downloading", 5, 10, 'data')
+ assert " - Downloading" in capsys.readouterr().out
+
+
+def test_console_callback_quiet(capsys):
+ cli.ConsoleCallback({'quiet': True}).message("hidden")
+ cli.ConsoleCallback({'quiet': False}).message("shown")
+ assert capsys.readouterr().out == "shown\n"
+
+ assert cli.ConsoleCallback({'quiet': False}).message_box("ignored") is None
+
+
+################################################################################
+## Commands
+def test_list(cli_hm, capsys):
+ assert cli.do_list(cli_hm, []) == 0
+ out = capsys.readouterr().out
+
+ assert "Available ports:" in out
+ for port in ("sprout.zip", "100liljumps.zip", "2048plus.zip"):
+ assert port in out
+
+ ## Experimental ports are hidden by default.
+ assert "starfighter.zip" not in out
+ assert "Filters: " in out and "platformer" in out
+
+
+def test_list_with_filters(cli_hm, capsys):
+ cli.do_list(cli_hm, ["puzzle"])
+ out = capsys.readouterr().out
+
+ assert "2048plus.zip" in out
+ assert "sprout.zip" not in out
+ ## The filters asked for aren't offered again.
+ assert "puzzle" not in out.split("Filters: ")[1]
+
+
+def test_list_unknown_filter(cli_hm, capsys):
+ cli.do_list(cli_hm, ["no-such-genre"])
+ out = capsys.readouterr().out
+
+ assert ".zip" not in out
+ assert "Filters: \n" in out
+
+
+def test_ports_empty(cli_hm, capsys):
+ assert cli.do_ports(cli_hm, []) == 0
+ assert "No ports found." in capsys.readouterr().out
+
+
+def test_ports_installed_unknown_and_broken(cli_hm, hm_dirs, capsys):
+ ports_dir = hm_dirs['ports_dir']
+ assert cli_hm.install_port(str(write_port_zip(hm_dirs['temp_dir'] / "testport.zip", basic_port_files()))) == 0
+ assert cli_hm.install_port(str(write_port_zip(
+ hm_dirs['temp_dir'] / "other.zip", basic_port_files(name="other", script="Other.sh")))) == 0
+
+ (ports_dir / "Mystery.sh").write_text("#!/bin/bash\n")
+ (ports_dir / "Other.sh").unlink()
+ cli_hm.load_ports()
+
+ cli.do_ports(cli_hm, [])
+ out = capsys.readouterr().out
+
+ assert "Installed Ports:\n- testport.zip" in out
+ assert "Unknown Ports:\n- Mystery.sh" in out
+ assert "Broken Ports:\n- other.zip" in out
+ assert "No ports found." not in out
+
+
+def test_portsmd(cli_hm, capsys):
+ assert cli.do_portsmd(cli_hm, ["", "platformer,", ","]) == 0
+ out = capsys.readouterr().out
+
+ assert 'Title="-SPROUT- ."' in out
+ assert 'Title="100_Lil_Jumps ."' in out
+ assert 'runtype="rtr"' in out
+ assert 'locat="sprout.zip"' in out
+ assert "2048" not in out
+
+ filters = out.split('Filters="')[1]
+ assert "installed" not in filters and "platformer" not in filters
+
+
+def test_portsmd_title_kinds(cli_hm):
+ info = cli_hm.port_info("sprout.zip")
+
+ info['attr']['reqs'] = ['opengl']
+ assert cli_hm.portmd(info).startswith('Title_F')
+
+ info['attr']['reqs'] = ['power']
+ assert cli_hm.portmd(info).startswith('Title_P')
+
+ info['attr']['desc'] = "None"
+ info['attr']['runtime'] = "mono-6.12.0.122-aarch64.squashfs"
+ md = cli_hm.portmd(info)
+ assert 'Desc=""' in md
+ assert 'mono="y"' in md
+
+
+def test_portsjson_to_file(cli_hm, tmp_path):
+ out_file = tmp_path / "ports.json"
+ assert cli.do_portsjson(cli_hm, [str(out_file)]) == 0
+
+ data = json.loads(out_file.read_text())
+ names = [port['name'] for port in data['ports']]
+ assert sorted(names) == ["100liljumps.zip", "2048plus.zip", "sprout.zip"]
+
+ sprout = data['ports'][names.index("sprout.zip")]
+ assert sprout['download_url'].endswith("/sprout.zip")
+ assert sprout['download_size'] == 8159843
+ assert set(sprout['attr']['media']) == {"screenshot", "cover", "thumbnail", "video"}
+ assert 'status' not in sprout and 'files' not in sprout
+
+ assert "ags_3.6.squashfs" in data['utils']
+ assert data['utils']['ags_3.6.squashfs']['download_size'] > 0
+
+
+def test_portsjson_stdout_rewrites_release_urls(cli_hm, capsys):
+ source = cli_hm.sources['pm']
+ source._data['sprout.zip']['url'] = "https://github.com/x/PortMaster-Releases/releases/download/2024-01-02_1234/sprout.zip"
+
+ cli.do_portsjson(cli_hm, [])
+ data = json.loads(capsys.readouterr().out)
+
+ sprout = [port for port in data['ports'] if port['name'] == "sprout.zip"][0]
+ assert sprout['download_url'] == "https://github.com/x/PortMaster-Releases/releases/latest/download/sprout.zip"
+
+
+def test_install_and_uninstall(cli_hm, hm_dirs):
+ assert cli.do_install(cli_hm, ["2048plus.zip"]) == 0
+ assert (hm_dirs['ports_dir'] / "2048plus").is_dir()
+ ## Quiet is switched off while installing, and back afterwards.
+ assert cli_hm.callback.config['quiet'] is True
+
+ ## Each CLI run is one command: installing doesn't reload the installed ports.
+ cli_hm.load_ports()
+
+ assert cli.do_uninstall(cli_hm, ["2048plus.zip"]) == 0
+ assert not (hm_dirs['ports_dir'] / "2048plus").exists()
+ assert cli_hm.callback.config['quiet'] is True
+
+
+def test_install_stops_at_the_first_failure(cli_hm, hm_dirs):
+ assert cli.do_install(cli_hm, ["missing.zip", "2048plus.zip"]) != 0
+ assert not (hm_dirs['ports_dir'] / "2048plus").exists()
+ assert cli_hm.callback.config['quiet'] is True
+
+
+def test_uninstall_unknown_port(cli_hm):
+ assert cli.do_uninstall(cli_hm, ["missing.zip"]) != 0
+ assert cli_hm.callback.config['quiet'] is True
+
+
+@pytest.mark.parametrize("command", [cli.do_install, cli.do_uninstall, cli.do_runtime_check])
+def test_commands_without_arguments_show_their_help(cli_hm, command, capsys):
+ assert command(cli_hm, []) == 0
+ out = capsys.readouterr().out
+
+ assert out.startswith("Missing arguments.")
+ assert command.__name__[3:] in out
+
+
+def test_runtime_list(cli_hm, capsys):
+ (cli_hm.libs_dir / "gmtoolkit.squashfs").write_bytes(b"x")
+
+ assert cli.do_runtime_list(cli_hm, []) == 0
+ out = capsys.readouterr().out.splitlines()
+
+ assert out[0] == "Available Runtimes:"
+ assert "- gmtoolkit.squashfs (installed)" in out
+ assert "- ags_3.6.squashfs" in out
+ ## Listed once each, sorted.
+ assert out[1:] == sorted(set(out[1:]))
+
+
+def test_runtime_check_restores_quiet(cli_hm, monkeypatch):
+ monkeypatch.setattr(cli_hm, "check_runtime", lambda name: 42)
+
+ assert cli.do_runtime_check(cli_hm, ["gmtoolkit.squashfs"]) == 42
+ assert cli_hm.callback.config['quiet'] is True
+
+
+def test_reload(cli_hm, hm_dirs):
+ assert cli_hm.install_port(str(write_port_zip(hm_dirs['temp_dir'] / "testport.zip", basic_port_files()))) == 0
+ cli_hm.installed_ports = {}
+
+ assert cli.do_reload(cli_hm, []) == 0
+ assert list(cli_hm.installed_ports) == ["testport.zip"]
+
+
+def test_update_sources(cli_hm, capsys, monkeypatch):
+ updated = []
+ for prefix, source in cli_hm.sources.items():
+ monkeypatch.setattr(source, "update", lambda prefix=prefix: updated.append(prefix))
+
+ assert cli.do_update(cli_hm, []) == 0
+ assert sorted(updated) == ['pm', 'pmmv']
+
+ updated.clear()
+ assert cli.do_update(cli_hm, ["ALL"]) == 0
+ assert sorted(updated) == ['pm', 'pmmv']
+
+ updated.clear()
+ assert cli.do_update(cli_hm, ["pmmv", "nope"]) == 0
+ assert updated == ['pmmv']
+ assert "Unknown source nope" in capsys.readouterr().out
+
+
+def test_auto_update(cli_hm, monkeypatch):
+ called = []
+ for prefix, source in cli_hm.sources.items():
+ monkeypatch.setattr(source, "auto_update", lambda prefix=prefix: called.append(prefix))
+
+ assert cli.do_auto_update(cli_hm, []) == 0
+ assert sorted(called) == ['pm', 'pmmv']
+
+
+def test_device_info(cli_hm, tmp_path, capsys, monkeypatch):
+ ## hardware.DEVICES is an empty shim since device_info.txt took over.
+ monkeypatch.setattr(harbourmaster, "DEVICES", {
+ "rg35xx": {'device': "rg35xx", 'manufacturer': "Anbernic", 'cfw': ["muOS", "Knulli"]}})
+
+ out_file = tmp_path / "devices.json"
+ cli.do_device_info(cli_hm, [str(out_file)])
+ data = json.loads(out_file.read_text())
+
+ assert list(data) == ["rg35xx"]
+ assert sorted(data["rg35xx"]) == ["Knulli", "muOS"]
+ info = data["rg35xx"]["muOS"]
+ assert info['name'] == "muos" and info['manufacturer'] == "Anbernic"
+ assert info['version'] == "N/A"
+ assert 'capabilities' in info and 'resolution' in info
+
+ cli.do_device_info(cli_hm, [])
+ assert json.loads(capsys.readouterr().out) == data
+
+
+def test_help(cli_hm, capsys):
+ assert cli.do_help(cli_hm, []) == 0
+ out = capsys.readouterr().out
+ assert "All available commands:" in out
+ assert "--offline" in out
+
+ assert cli.do_help(cli_hm, ["LIST"]) == 0
+ assert "List available ports" in capsys.readouterr().out
+
+ assert cli.do_help(cli_hm, ["nope"]) == 255
+ out = capsys.readouterr().out
+ assert "unknown help command nope" in out
+ assert "All available commands:" in out
+
+
+def test_every_command_has_help(cli_hm, capsys):
+ ## `help device_info` crashed: it had no docstring.
+ for name, command in cli.all_commands.items():
+ assert cli.do_help(cli_hm, [name]) == 0, name
+ assert "{command}" not in capsys.readouterr().out
+
+
+################################################################################
+## fifo_control
+def test_fifo_control_needs_two_paths(cli_hm):
+ assert cli.do_fifo_control(cli_hm, ["one"]) == 0
+
+
+def test_fifo_control_commands(cli_hm, tmp_path, monkeypatch):
+ import threading
+
+ fifo = tmp_path / "hm_input"
+ done = tmp_path / "hm_done"
+ out_file = tmp_path / "ports.md"
+ ## Left over from a previous run.
+ fifo.write_text("stale")
+ done.write_text("stale")
+
+ reloaded = []
+ monkeypatch.setitem(cli.fifo_commands, 'reload', lambda hm, argv: reloaded.append(argv))
+
+ result = []
+ thread = threading.Thread(target=lambda: result.append(cli.do_fifo_control(cli_hm, [str(fifo), str(done)])))
+ thread.start()
+
+ ## One stat(): exists() then is_file() raced the stale file's removal, and the
+ ## open() below then made a plain file where the pipe was going (FileExistsError).
+ import stat
+ import time
+
+ def is_pipe():
+ try:
+ return stat.S_ISFIFO(fifo.stat().st_mode)
+ except OSError:
+ return False
+
+ end = time.monotonic() + 10
+ while not is_pipe():
+ assert time.monotonic() < end, "fifo_control never made its pipe"
+ time.sleep(0.01)
+
+ with open(str(fifo), 'w') as fh:
+ fh.write("\n") ## blank: skipped
+ fh.write("onlyone\n") ## no output file part: skipped
+ fh.write(f"portsmd:{out_file}:puzzle\n")
+ fh.write("RELOAD::a:b\n")
+ fh.write("exit\n")
+
+ thread.join(10)
+ assert not thread.is_alive()
+ assert result == [0]
+
+ assert '2048plus.zip' in out_file.read_text()
+ assert 'sprout.zip' not in out_file.read_text()
+ assert reloaded == [['a', 'b']]
+ assert done.is_file()
+ ## The pipe is removed afterwards.
+ assert not fifo.exists()
+
+
+################################################################################
+## main()
+@pytest.fixture
+def run_main(hm_dirs, callback, monkeypatch):
+ made = []
+
+ def make_hm(config, temp_dir=None, callback=None):
+ hm = harbourmaster.HarbourMaster(config, temp_dir=temp_dir, callback=callback, **{
+ key: value for key, value in hm_dirs.items() if key != 'temp_dir'})
+ made.append((config, hm))
+ return hm
+
+ monkeypatch.setattr(cli, "HarbourMaster", make_hm)
+
+ def run(*args):
+ result = cli.main(["harbourmaster", *args])
+ return result, (made[-1][0] if made else None)
+
+ return run
+
+
+def test_main_no_command_shows_help(run_main, capsys):
+ result, config = run_main("--offline")
+
+ assert result == 1
+ assert config['offline'] and config['no-check']
+ assert "All available commands:" in capsys.readouterr().out
+
+
+def test_main_help_flag(run_main, capsys):
+ result, config = run_main("--offline", "--help", "install")
+
+ assert result == 1
+ assert "Install a port" in capsys.readouterr().out
+
+
+def test_main_nothing(run_main, capsys):
+ assert run_main("--offline", "nothing")[0] == 0
+ assert "All available commands:" not in capsys.readouterr().out
+
+
+def test_main_unknown_command(run_main, capsys):
+ assert run_main("--offline", "frobnicate")[0] == 2
+ assert "Command frobnicate not found." in capsys.readouterr().out
+
+
+def test_main_unknown_flag_is_dropped(run_main, capsys):
+ result, config = run_main("--offline", "--frobnicate", "ports")
+
+ assert result == 0
+ assert 'frobnicate' not in config
+ assert "No ports found." in capsys.readouterr().out
+
+
+def test_main_double_dash_ends_flags(run_main, capsys):
+ ## After `--` a flag-looking word is an argument: here a help topic.
+ result, config = run_main("--offline", "help", "--", "--quiet")
+
+ assert result == 255
+ assert config['quiet'] is False
+ assert "unknown help command --quiet" in capsys.readouterr().out
+
+
+def test_main_command_is_case_insensitive(run_main, capsys):
+ assert run_main("--offline", "PORTS")[0] == 0
+ assert "No ports found." in capsys.readouterr().out
+
+
+def test_main_colour_flags(run_main, monkeypatch):
+ modes = []
+ monkeypatch.setattr(cli.console, "do_color", lambda mode=None: modes.append(mode))
+
+ run_main("--offline", "--no-colour", "nothing")
+ run_main("--offline", "--force-colour", "nothing")
+ ## no-colour wins.
+ run_main("--offline", "--force-colour", "--no-colour", "nothing")
+
+ assert modes == [False, True, False]
+
+
+def test_main_debug_flag_file(run_main, monkeypatch, tmp_path):
+ flag_dir = tmp_path / "flagtools"
+ (flag_dir / "PortMaster").mkdir(parents=True)
+ (flag_dir / "PortMaster" / "debug_all_the_things_flag").write_text("")
+ monkeypatch.setattr(harbourmaster, "HM_TOOLS_DIR", flag_dir)
+
+ removed = []
+ monkeypatch.setattr(cli.logger, "remove", lambda handler=None: removed.append(handler))
+ monkeypatch.setattr(cli.logger, "add", lambda *args, **kwargs: 99)
+
+ result, config = run_main("--offline", "nothing")
+
+ assert result == 0
+ assert config['debug'] is True
+ assert removed == [0]
+
+
+def test_main_no_log_without_a_log_file(run_main, monkeypatch, capsys):
+ ## With no PortMaster folder at import there was no LOG_FILE_HANDLE at all: --no-log
+ ## raised NameError, which logger.catch swallowed, so the command never ran.
+ removed = []
+ monkeypatch.setattr(cli, "LOG_FILE_HANDLE", None)
+ monkeypatch.setattr(cli.logger, "remove", lambda handler=None: removed.append(handler))
+
+ assert run_main("--offline", "--no-log", "ports")[0] == 0
+ assert "No ports found." in capsys.readouterr().out
+ assert None not in removed
diff --git a/tests/test_config.py b/tests/test_config.py
index 5daaef43..ed77f55a 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -174,3 +174,56 @@ def test_detect_retrodeck_json_portmaster_path(tmp_path):
{"paths": {"rd_home_path": "/home/deck/retrodeck", "portmaster_path": "/media/pm"}}))
assert detect(tmp_path)[1] == Path("/media/pm/ports")
+
+
+def test_detect_trimui_miyoo_ex(tmp_path):
+ make_dirs(tmp_path, "/mnt/SDCARD/MIYOO_EX/PortMaster")
+ assert detect(tmp_path)[:3] == (
+ Path("/mnt/SDCARD/MIYOO_EX/PortMaster"), Path("/mnt/SDCARD/MIYOO_EX/ports"), Path("/mnt/SDCARD/MIYOO_EX/ports"))
+
+
+def test_detect_retrodeck_json_in_home(tmp_path):
+ ## The flatpak keeps it under ~/.var.
+ config = tmp_path / "home/.var/app/net.retrodeck.retrodeck/config/retrodeck"
+ config.mkdir(parents=True)
+ (config / "retrodeck.json").write_text(json.dumps(
+ {"paths": {"rd_home_path": "/rd", "ports_path": "/rd/pm"}}))
+
+ assert detect(tmp_path)[:3] == (
+ tmp_path / "home/.var/app/net.retrodeck.retrodeck/data", Path("/rd/pm/ports"), Path("/rd/roms/portmaster"))
+
+
+@pytest.mark.parametrize("text", ["{", "[]", '{"paths": {}}', '{"other": 1}'])
+def test_detect_retrodeck_json_broken_exits(tmp_path, text):
+ make_dirs(tmp_path, "/var/config/retrodeck")
+ (tmp_path / "var/config/retrodeck/retrodeck.json").write_text(text)
+
+ with pytest.raises(SystemExit) as exc:
+ detect(tmp_path)
+
+ assert exc.value.code == 255
+
+
+def test_detect_retrodeck_cfg(tmp_path):
+ make_dirs(tmp_path, "/var/config/retrodeck")
+ (tmp_path / "var/config/retrodeck/retrodeck.cfg").write_text(
+ "[paths]\nrdhome=/rd\nports_folder=/media/pm\nroms_folder=/media/roms\n")
+
+ assert detect(tmp_path)[:3] == (Path("/var/data"), Path("/media/pm/ports"), Path("/media/roms/portmaster"))
+
+
+def test_detect_retrodeck_cfg_in_home_defaults(tmp_path):
+ config = tmp_path / "home/.var/app/net.retrodeck.retrodeck/config/retrodeck"
+ config.mkdir(parents=True)
+ (config / "retrodeck.cfg").write_text("rdhome=/rd\n")
+
+ assert detect(tmp_path)[:3] == (
+ tmp_path / "home/.var/app/net.retrodeck.retrodeck/data", Path("/rd/PortMaster/ports"), Path("/rd/roms/portmaster"))
+
+
+def test_detect_retrodeck_cfg_without_rdhome_exits(tmp_path):
+ make_dirs(tmp_path, "/var/config/retrodeck")
+ (tmp_path / "var/config/retrodeck/retrodeck.cfg").write_text("roms_folder=/r\n")
+
+ with pytest.raises(SystemExit):
+ detect(tmp_path)
diff --git a/tests/test_events.py b/tests/test_events.py
new file mode 100644
index 00000000..ee553568
--- /dev/null
+++ b/tests/test_events.py
@@ -0,0 +1,257 @@
+# SPDX-License-Identifier: MIT
+#
+# pugwash.sdl.events.EventManager: SDL keyboard, controller and stick events pushed
+# onto SDL's queue, turned into named buttons.
+
+import ctypes
+
+import pytest
+
+
+pytestmark = pytest.mark.sdl
+
+
+@pytest.fixture
+def events(gui, monkeypatch):
+ import sdl2
+ events = gui.events
+ saved = (dict(events.buttons), dict(events.last_buttons), dict(events.repeat), dict(events.axis_button), events.running)
+
+ sdl2.SDL_FlushEvents(sdl2.SDL_FIRSTEVENT, sdl2.SDL_LASTEVENT)
+ for key in events.buttons:
+ events.buttons[key] = False
+ events.last_buttons.update(events.buttons)
+ for key in events.repeat:
+ events.repeat[key] = None
+ for key in events.axis_button:
+ events.axis_button[key] = None
+
+ yield events
+
+ sdl2.SDL_FlushEvents(sdl2.SDL_FIRSTEVENT, sdl2.SDL_LASTEVENT)
+ events.buttons.clear()
+ events.buttons.update(saved[0])
+ events.last_buttons.clear()
+ events.last_buttons.update(saved[1])
+ events.repeat.update(saved[2])
+ events.axis_button.update(saved[3])
+ events.running = saved[4]
+
+
+def push(event):
+ import sdl2
+ assert sdl2.SDL_PushEvent(ctypes.byref(event)) == 1
+
+
+def key(sym, down=True):
+ import sdl2
+ event = sdl2.SDL_Event()
+ event.type = sdl2.SDL_KEYDOWN if down else sdl2.SDL_KEYUP
+ event.key.keysym.sym = sym
+ push(event)
+
+
+def button(which, down=True):
+ import sdl2
+ event = sdl2.SDL_Event()
+ event.type = sdl2.SDL_CONTROLLERBUTTONDOWN if down else sdl2.SDL_CONTROLLERBUTTONUP
+ event.cbutton.button = which
+ push(event)
+
+
+def axis(which, value):
+ import sdl2
+ event = sdl2.SDL_Event()
+ event.type = sdl2.SDL_CONTROLLERAXISMOTION
+ event.caxis.axis = which
+ event.caxis.value = value
+ push(event)
+
+
+def test_every_button_is_known(events):
+ for name in ('A', 'START', 'SELECT', 'SCRN', 'L_LEFT', 'R_DOWN', 'L2', 'R2'):
+ assert name in events.buttons, name
+
+ ## Only names.
+ assert all(isinstance(name, str) for name in events.buttons)
+
+
+def test_keyboard(events):
+ import sdl2
+ key(sdl2.SDLK_z)
+ events.handle_events()
+
+ assert events.was_pressed('A')
+ assert events.any_pressed()
+ assert not events.was_released('A')
+
+ ## Held: no longer "was pressed".
+ events.handle_events()
+ assert not events.was_pressed('A')
+ assert not events.any_pressed()
+
+ key(sdl2.SDLK_z, down=False)
+ events.handle_events()
+ assert events.was_released('A')
+ assert events.any_released()
+
+
+def test_unknown_key_is_ignored(events):
+ import sdl2
+ key(sdl2.SDLK_F12)
+ events.handle_events()
+
+ assert not events.any_pressed()
+
+
+def test_escape_and_quit_stop(events):
+ import sdl2
+ key(sdl2.SDLK_ESCAPE)
+ events.handle_events()
+ assert events.running is False
+
+ events.running = True
+ event = sdl2.SDL_Event()
+ event.type = sdl2.SDL_QUIT
+ push(event)
+ events.handle_events()
+ assert events.running is False
+
+
+def test_controller_buttons(events):
+ import sdl2
+ button(sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP)
+ button(sdl2.SDL_CONTROLLER_BUTTON_START)
+ events.handle_events()
+
+ assert events.was_pressed('UP') and events.was_pressed('START')
+ assert events.repeat['UP'] is not None
+
+ button(sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP, down=False)
+ events.handle_events()
+ assert events.was_released('UP')
+ assert events.repeat['UP'] is None
+
+
+def test_unmapped_controller_button(events):
+ import sdl2
+ button(sdl2.SDL_CONTROLLER_BUTTON_MISC1)
+ button(sdl2.SDL_CONTROLLER_BUTTON_MISC1, down=False)
+ events.handle_events()
+
+ assert not events.any_pressed()
+
+
+def test_keyboard_repeat(events, monkeypatch):
+ import sdl2
+ now = [1000]
+ monkeypatch.setattr(sdl2, "SDL_GetTicks64", lambda: now[0])
+
+ key(sdl2.SDLK_DOWN)
+ events.handle_events()
+ assert events.was_pressed('DOWN')
+
+ ## Held, before the delay: nothing.
+ now[0] += events.REPEAT_DELAY - 1
+ events.handle_events()
+ assert not events.was_pressed('DOWN')
+
+ ## After it: pressed again, then every REPEAT_RATE.
+ now[0] += 1
+ events.handle_events()
+ assert events.was_pressed('DOWN')
+
+ now[0] += events.REPEAT_RATE
+ events.handle_events()
+ assert events.was_pressed('DOWN')
+
+ key(sdl2.SDLK_DOWN, down=False)
+ events.handle_events()
+ now[0] += 10_000
+ events.handle_events()
+ assert not events.was_pressed('DOWN')
+
+
+def test_stick(events):
+ import sdl2
+ LX = sdl2.SDL_CONTROLLER_AXIS_LEFTX
+
+ ## Inside the dead zone: nothing.
+ axis(LX, events.analog_min - 1)
+ events.handle_events()
+ assert not events.any_pressed()
+
+ axis(LX, 30000)
+ events.handle_events()
+ assert events.was_pressed('L_RIGHT')
+ assert events.axis['LX'] == 30000
+
+ ## Straight across: right is let go, left pressed.
+ axis(LX, -30000)
+ events.handle_events()
+ assert events.buttons['L_RIGHT'] is False
+ assert events.was_pressed('L_LEFT')
+
+ axis(LX, 0)
+ events.handle_events()
+ assert events.was_released('L_LEFT')
+ assert events.axis_button['LX'] is None
+
+
+def test_trigger(events):
+ import sdl2
+ axis(sdl2.SDL_CONTROLLER_AXIS_TRIGGERRIGHT, 32000)
+ events.handle_events()
+ assert events.was_pressed('R2')
+
+ ## A trigger has no "negative" button.
+ axis(sdl2.SDL_CONTROLLER_AXIS_TRIGGERRIGHT, 0)
+ events.handle_events()
+ assert events.was_released('R2')
+
+
+def test_unknown_axis(events):
+ import sdl2
+ axis(sdl2.SDL_CONTROLLER_AXIS_MAX, 30000)
+ events.handle_events()
+ assert not events.any_pressed()
+
+
+@pytest.mark.parametrize("value, expected", [(0, 1), (4095, 1), (-4095, 1), (4096, 2), (-4096, 0), (32767, 2), (-32768, 0)])
+def test_axis_map(events, value, expected):
+ assert events._axis_map(value, 4096) == expected
+
+
+def test_controller_hotplug(events, monkeypatch):
+ import sdl2
+ opened, closed = [], []
+ monkeypatch.setattr(sdl2, "SDL_GameControllerOpen", lambda which: opened.append(which))
+ monkeypatch.setattr(sdl2, "SDL_GameControllerFromInstanceID", lambda which: f"pad{which}")
+ monkeypatch.setattr(sdl2, "SDL_GameControllerClose", lambda pad: closed.append(pad))
+
+ for kind, which in ((sdl2.SDL_CONTROLLERDEVICEADDED, 3), (sdl2.SDL_CONTROLLERDEVICEREMOVED, 7)):
+ event = sdl2.SDL_Event()
+ event.type = kind
+ event.cdevice.which = which
+ push(event)
+
+ events.handle_events()
+ assert opened == [3] and closed == ["pad7"]
+
+
+def test_xbox_fix_once(gui):
+ import sdl2
+ events = gui.events
+ saved = (dict(events.BUTTON_MAP), events.XBOX_FIXED)
+ try:
+ events.XBOX_FIXED = False
+ events.fix_xbox_mode()
+ events.fix_xbox_mode()
+
+ assert events.BUTTON_MAP[sdl2.SDL_CONTROLLER_BUTTON_A] == 'B'
+ assert events.BUTTON_MAP[sdl2.SDL_CONTROLLER_BUTTON_X] == 'Y'
+
+ finally:
+ events.BUTTON_MAP.clear()
+ events.BUTTON_MAP.update(saved[0])
+ events.XBOX_FIXED = saved[1]
diff --git a/tests/test_fifo_commands.py b/tests/test_fifo_commands.py
new file mode 100644
index 00000000..64bc832a
--- /dev/null
+++ b/tests/test_fifo_commands.py
@@ -0,0 +1,287 @@
+# SPDX-License-Identifier: MIT
+#
+# pugwash's fifo_control commands (app_fifo.py) other than install / autoinstall, and
+# the do_fifo_control loop itself, in-process on the shared headless GUI.
+
+import json
+import stat
+import threading
+import time
+
+import pytest
+
+
+pytestmark = pytest.mark.sdl
+
+
+class FifoWriter:
+ """What PortMasterDialog.txt does: write a command, wait for the done file to answer."""
+ TIMEOUT = 10
+
+ def __init__(self, pipe, done):
+ self.pipe = pipe
+ self.done = done
+
+ def wait_ready(self):
+ end = time.monotonic() + self.TIMEOUT
+ while time.monotonic() < end:
+ try:
+ if stat.S_ISFIFO(self.pipe.stat().st_mode) and self.done.read_text() == "DONE":
+ return
+ except OSError:
+ pass
+
+ time.sleep(0.01)
+
+ raise AssertionError("fifo_control never started")
+
+ def send_raw(self, text):
+ with open(str(self.pipe), "w") as fh:
+ fh.write(text)
+
+ def send(self, *args):
+ self.done.write_text("WAIT")
+ self.send_raw("".join(f"{arg}\1" for arg in args) + "\n")
+
+ end = time.monotonic() + self.TIMEOUT
+ while time.monotonic() < end:
+ answer = self.done.read_text()
+ if answer not in ("", "WAIT"):
+ return answer
+
+ time.sleep(0.01)
+
+ raise AssertionError(f"no answer to {args}")
+
+
+@pytest.fixture
+def fifo(gui, tmp_path, monkeypatch):
+ from pugwash import tasks
+
+ done = tasks.DoneFile(tmp_path / "pm_done")
+ done.write_text("WAIT")
+ config = {'done-file': done, 'register': {}}
+
+ calls = []
+ monkeypatch.setattr(gui, "message", lambda text: calls.append(('message', text)))
+ monkeypatch.setattr(gui, "progress", lambda *args: calls.append(('progress',) + args))
+ gui.fifo_calls = calls
+
+ def run(command, *args):
+ getattr(gui, f"fifo_{command}")(config, list(args))
+ return done.read_text()
+
+ gui.run_fifo = run
+ gui.fifo_config = config
+ yield gui
+
+ while gui.message_box_depth > 0:
+ gui.messages_end(internal=True)
+
+
+################################################################################
+## Registers
+def test_register_set_and_dump(fifo):
+ assert fifo.run_fifo("reg_set_info", "ports", "a.zip", "title:A Port", "desc:line one\\nline two", "no colon") == "DONE"
+ assert fifo.run_fifo("reg_set_info", "ports", "a.zip", "status:ok") == "DONE"
+
+ assert json.loads(fifo.run_fifo("reg_dump", "ports")) == {
+ "a.zip": {"title": "A Port", "desc": "line one\nline two", "status": "ok"}}
+
+
+@pytest.mark.parametrize("command, args", [
+ ("reg_set_info", ("ports", "a.zip")),
+ ("reg_clear_info", ("ports",)),
+ ("reg_clear", ()),
+ ("reg_dump", ()),
+ ("reg_dump", ("missing",)),
+ ])
+def test_register_bad_arguments(fifo, command, args):
+ assert fifo.run_fifo(command, *args) == "FAIL"
+
+
+def test_register_clear_info(fifo):
+ ## It unpacked three values from two: every call raised ValueError.
+ fifo.run_fifo("reg_set_info", "ports", "a.zip", "title:A")
+ fifo.run_fifo("reg_set_info", "ports", "b.zip", "title:B")
+
+ assert fifo.run_fifo("reg_clear_info", "ports", "a.zip") == "DONE"
+ assert json.loads(fifo.run_fifo("reg_dump", "ports")) == {"b.zip": {"title": "B"}}
+
+ ## Not there: still done.
+ assert fifo.run_fifo("reg_clear_info", "ports", "missing.zip") == "DONE"
+ assert fifo.run_fifo("reg_clear_info", "other", "x") == "DONE"
+
+
+def test_register_clear(fifo):
+ fifo.run_fifo("reg_set_info", "ports", "a.zip", "title:A")
+
+ assert fifo.run_fifo("reg_clear", "ports") == "DONE"
+ assert fifo.run_fifo("reg_dump", "ports") == "FAIL"
+ assert fifo.run_fifo("reg_clear", "ports") == "DONE"
+
+
+################################################################################
+## Messages and progress
+def test_messages(fifo):
+ assert fifo.run_fifo("messages_end") == "FAIL"
+ assert fifo.run_fifo("messages_begin") == "DONE"
+ ## Not twice.
+ assert fifo.run_fifo("messages_begin") == "FAIL"
+
+ assert fifo.run_fifo("message", "Hello\\nWorld") == "DONE"
+ assert fifo.run_fifo("message") == "FAIL"
+ assert ('message', "Hello\nWorld") in fifo.fifo_calls
+
+ assert fifo.run_fifo("messages_end") == "DONE"
+
+
+@pytest.mark.parametrize("args, expected", [
+ (("Copying",), ("Copying", 0, 100, None)),
+ (("Copying", "5"), ("Copying", 5, 100, None)),
+ (("Copying", "5", "20"), ("Copying", 5, 20, None)),
+ (("Copying", "30", "20"), ("Copying", 20, 30, None)),
+ (("Copying", "0", "0"), ("Copying", 0, 100, None)),
+ (("Copying", "x", "y"), ("Copying", 0, 100, None)),
+ ## The format: it went into total, so it was never used (and "abc" as the total crashed).
+ (("Copying", "5", "20", "data"), ("Copying", 5, 20, 'data')),
+ (("Copying", "5", "abc", "%"), ("Copying", 5, 100, '%')),
+ (("Copying", "5", "20", "bogus"), ("Copying", 5, 20, None)),
+ ])
+def test_progress(fifo, args, expected):
+ assert fifo.run_fifo("progress", *args) == "DONE"
+ assert fifo.fifo_calls[-1] == ('progress',) + expected
+
+
+def test_progress_bad(fifo):
+ assert fifo.run_fifo("progress") == "FAIL"
+ assert fifo.run_fifo("progress_clear") == "DONE"
+ assert fifo.fifo_calls[-1] == ('progress', None, None, None)
+
+
+################################################################################
+## Message boxes and selection lists (their option parsing)
+@pytest.fixture
+def boxes(fifo, monkeypatch):
+ shown = []
+ fifo.box_answer = True
+
+ def message_box(text, **kwargs):
+ shown.append((text, kwargs))
+ return fifo.box_answer
+
+ monkeypatch.setattr(fifo, "message_box", message_box)
+ return shown
+
+
+@pytest.mark.parametrize("args", [(), ("--want-cancel",), ("--weird",)])
+def test_message_box_nothing_to_show(fifo, boxes, args):
+ assert fifo.run_fifo("message_box", *args) == "TRUE"
+ assert boxes == []
+
+
+def test_message_box(fifo, boxes):
+ assert fifo.run_fifo("message_box", "--want-cancel", "--ok-text", "Yes", "--cancel-text", "No", "--odd", "Line\\nTwo") == "TRUE"
+ assert boxes == [("Line\nTwo", {'want_cancel': True, 'ok_text': "Yes", 'cancel_text': "No"})]
+
+ fifo.box_answer = False
+ assert fifo.run_fifo("message_box", "Again") == "FALSE"
+
+
+def test_message_box_double_dash(fifo, boxes):
+ assert fifo.run_fifo("message_box", "--", "--not an option") == "TRUE"
+ assert boxes[-1][0] == "--not an option"
+
+
+def test_message_box_option_without_its_value(fifo, boxes):
+ ## A trailing --ok-text has nothing to take: it's dropped.
+ assert fifo.run_fifo("message_box", "Text", "--ok-text") == "TRUE"
+ assert boxes[-1] == ("Text", {'want_cancel': False})
+
+
+@pytest.fixture
+def selections(fifo, monkeypatch):
+ shown = []
+ fifo.selection_answer = "b"
+
+ def show_selection_list(config, register):
+ shown.append((config, register))
+ return fifo.selection_answer
+
+ monkeypatch.setattr(fifo, "show_selection_list", show_selection_list)
+ return shown
+
+
+def test_selection_list_options(fifo, selections):
+ fifo.run_fifo("reg_set_info", "choices", "b", "title:B")
+
+ assert fifo.run_fifo(
+ "selection_list", "--cancel-text=Nope", "--ok-text=Yep", "--register=choices",
+ "--want-cancel", "--want-description", "--want-images", "--strange", "Title") == "b"
+
+ config, register = selections[-1]
+ assert config['cancel_text'] == "Nope" and config['ok_text'] == "Yep"
+ assert config['want_cancel'] and config['want_description'] and config['want_images']
+ assert register == {"b": {"title": "B"}}
+
+
+def test_selection_list_defaults(fifo, selections):
+ fifo.run_fifo("selection_list", "--register=missing", "--", "--want-cancel")
+
+ config, register = selections[-1]
+ assert config['want_cancel'] is False
+ assert register == {}
+
+
+def test_selection_list_cancelled(fifo, selections):
+ fifo.selection_answer = fifo.SELECTION_CANCELLED
+ assert fifo.run_fifo("selection_list", "--want-cancel") == "CANCEL"
+
+
+################################################################################
+## The loop
+def test_fifo_control_needs_two_paths(gui):
+ ## It checked the length of the second path, not of the arguments.
+ assert gui.do_fifo_control({}, ["only-one"]) == 0
+ assert gui.do_fifo_control({}, []) == 0
+
+
+def test_fifo_control_loop(gui, tmp_path, monkeypatch):
+ pipe = tmp_path / "pg_input"
+ done = tmp_path / "pg_done"
+ ## Left over from a crashed run.
+ pipe.write_text("old")
+ done.write_text("old")
+
+ def explode(self, fifo_config, args):
+ raise RuntimeError("boom")
+
+ monkeypatch.setitem(type(gui).fifo_commands, 'explode', explode)
+
+ writer = FifoWriter(pipe, done)
+ answers = {}
+
+ def script():
+ writer.wait_ready()
+ answers['set'] = writer.send("register_set_info", "r", "k", "a:1")
+ answers['dump'] = writer.send("REGISTER_DUMP", "r")
+ answers['unknown'] = writer.send("frobnicate")
+ answers['explode'] = writer.send("explode")
+ answers['after'] = writer.send("register_dump", "r")
+ writer.send_raw("exit\n")
+
+ thread = threading.Thread(target=script, daemon=True)
+ thread.start()
+
+ assert gui.do_fifo_control({}, [str(pipe), str(done)]) == 0
+ thread.join(10)
+
+ assert answers['set'] == "DONE"
+ assert json.loads(answers['dump']) == {"k": {"a": "1"}}
+ assert answers['unknown'] == "DONE"
+ ## A command that raises still answers, and the loop carries on.
+ assert answers['explode'] == "FAIL"
+ assert json.loads(answers['after']) == {"k": {"a": "1"}}
+
+ assert done.read_text() == "DONE"
+ assert not pipe.exists()
diff --git a/tests/test_gui_scenes.py b/tests/test_gui_scenes.py
new file mode 100644
index 00000000..bd116627
--- /dev/null
+++ b/tests/test_gui_scenes.py
@@ -0,0 +1,755 @@
+# SPDX-License-Identifier: MIT
+#
+# The GUI's menus driven a button at a time on the shared headless GUI, with the
+# PortMaster source from tests/data/ports.json: main menu, ports lists, filters,
+# featured ports, options, language and the on screen keyboard.
+
+import json
+
+import pytest
+
+import harbourmaster
+
+
+pytestmark = pytest.mark.sdl
+
+
+@pytest.fixture
+def scene_gui(gui, online_hm, monkeypatch):
+ """The GUI on the root scene with a HarbourMaster; message boxes answer `gui.answer`."""
+ from pugwash import scenes
+
+ gui.hm = online_hm
+ gui.boxes = []
+ gui.answer = True
+ cancelled = []
+
+ def message_box(text, *args, **kwargs):
+ gui.boxes.append(text)
+ return gui.answer
+
+ monkeypatch.setattr(gui, "message_box", message_box)
+ monkeypatch.setattr(gui, "do_cancel", lambda: cancelled.append(True))
+ gui.cancelled = cancelled
+ gui.scenes_mod = scenes
+
+ sounds = (gui.sounds.music_is_disabled, gui.sounds.sound_is_disabled)
+ yield gui
+
+ while len(gui.scenes) > 1:
+ gui.pop_scene()
+
+ gui.sounds.music_is_disabled, gui.sounds.sound_is_disabled = sounds
+ gui.events.running = True
+ gui.hm = None
+
+
+def top(gui):
+ return gui.scenes[-1][1][-1]
+
+
+def press(gui, monkeypatch, button):
+ with monkeypatch.context() as patch:
+ patch.setattr(gui.events, "was_pressed", lambda pressed: pressed == button)
+ patch.setattr(gui.events, "any_pressed", lambda: True)
+ gui.do_loop(no_delay=True)
+
+ gui.do_loop(no_delay=True)
+
+
+def select(scene, option, tag='option_list'):
+ region = scene.tags[tag]
+ region.list_select(region.options.index(option))
+
+
+def choose(gui, monkeypatch, option, tag='option_list'):
+ select(top(gui), option, tag)
+ gui.do_loop(no_delay=True)
+ press(gui, monkeypatch, 'A')
+
+
+def push(gui, name, scene):
+ gui.push_scene(name, scene)
+ gui.do_loop(no_delay=True)
+ return scene
+
+
+def list_titles(scene):
+ return list(scene.tags['ports_list'].list)
+
+
+################################################################################
+## Main menu
+@pytest.fixture
+def main_menu(scene_gui):
+ return push(scene_gui, 'main', scene_gui.scenes_mod.MainMenuScene(scene_gui))
+
+
+@pytest.mark.parametrize("option, mode, base_filters", [
+ ("install", "install", []),
+ ("install-rtr", "install", ["rtr"]),
+ ("uninstall", "uninstall", ["installed"]),
+ ])
+def test_main_menu_port_lists(scene_gui, main_menu, monkeypatch, option, mode, base_filters):
+ choose(scene_gui, monkeypatch, option)
+
+ scene = top(scene_gui)
+ assert isinstance(scene, scene_gui.scenes_mod.PortsListScene)
+ assert scene.options['mode'] == mode
+ assert scene.options['base_filters'] == base_filters
+
+ press(scene_gui, monkeypatch, 'B')
+ assert top(scene_gui) is main_menu
+
+
+def test_main_menu_tooltip_follows_the_selection(scene_gui, main_menu):
+ select(main_menu, 'options')
+ scene_gui.do_loop(no_delay=True)
+
+ assert scene_gui.get_data('menu.selected') == 'options'
+
+
+def test_main_menu_options(scene_gui, main_menu, monkeypatch):
+ choose(scene_gui, monkeypatch, 'options')
+ assert isinstance(top(scene_gui), scene_gui.scenes_mod.OptionScene)
+
+
+def test_main_menu_exit(scene_gui, main_menu, monkeypatch):
+ choose(scene_gui, monkeypatch, 'exit')
+ assert scene_gui.cancelled == [True]
+
+
+@pytest.mark.parametrize("answer", [True, False])
+def test_main_menu_back_asks_first(scene_gui, main_menu, monkeypatch, answer):
+ scene_gui.answer = answer
+ press(scene_gui, monkeypatch, 'B')
+
+ assert "exit PortMaster" in scene_gui.boxes[-1]
+ assert scene_gui.cancelled == ([True] if answer else [])
+
+
+def test_main_menu_featured_without_any(scene_gui, main_menu, monkeypatch):
+ choose(scene_gui, monkeypatch, 'featured-ports')
+
+ assert "No featured ports found" in scene_gui.boxes[-1]
+ assert top(scene_gui) is main_menu
+
+
+def test_konami_code(scene_gui, main_menu, monkeypatch):
+ for button in main_menu.KONAMI_CODE[:-1]:
+ press(scene_gui, monkeypatch, button)
+
+ assert scene_gui.hm.cfg_data['konami'] is True
+ assert scene_gui.boxes[-1] == "Secret Mode Enabled"
+ ## Still on the main menu: B, A and START were swallowed.
+ assert top(scene_gui) is main_menu
+
+ for button in main_menu.KONAMI_CODE[:-1]:
+ press(scene_gui, monkeypatch, button)
+
+ assert scene_gui.hm.cfg_data['konami'] is False
+ assert scene_gui.boxes[-1] == "Secret Mode Disabled"
+
+
+def test_konami_code_broken_off(scene_gui, main_menu, monkeypatch):
+ for button in ('UP', 'UP', 'DOWN', 'X'):
+ press(scene_gui, monkeypatch, button)
+
+ assert main_menu.detecting_konami == 0
+ assert 'konami' not in scene_gui.hm.cfg_data
+
+
+################################################################################
+## Ports lists and filters
+@pytest.fixture
+def ports_list(scene_gui):
+ return push(scene_gui, 'ports', scene_gui.scenes_mod.PortsListScene(scene_gui, {'mode': 'install', 'name': None}))
+
+
+def test_ports_list(scene_gui, ports_list):
+ assert list_titles(ports_list) == ["-SPROUT-", "100 Lil Jumps", "2048 Plus"]
+ assert scene_gui.get_data('ports_list.total_ports') == "3"
+ assert scene_gui.get_data('port_info.title') == "-SPROUT-"
+ ## Install lists never offer broken ports as a filter.
+ assert 'broken' in ports_list.options['skip_genres']
+
+
+def test_ports_list_moving(scene_gui, ports_list):
+ assert ports_list.select_next_port() == "100liljumps.zip"
+ assert ports_list.select_next_port() == "2048plus.zip"
+ assert ports_list.select_next_port() == "sprout.zip"
+ assert ports_list.select_prev_port() == "2048plus.zip"
+ assert scene_gui.get_data('port_info.title') == "2048 Plus"
+
+
+def test_ports_list_selection_shows_the_port(scene_gui, ports_list):
+ ports_list.tags['ports_list'].list_select(2)
+ scene_gui.do_loop(no_delay=True)
+
+ assert scene_gui.get_data('ports_list.position') == "3"
+ assert scene_gui.get_data('port_info.title') == "2048 Plus"
+ assert ports_list.selected_port() == "2048plus.zip"
+
+
+def test_ports_list_try_to_select(scene_gui, ports_list):
+ ports_list.try_to_select("2048plus.zip", "")
+ assert ports_list.tags['ports_list'].selected == 2
+
+ ## Gone: the port before where it would be.
+ ports_list.try_to_select("missing.zip", "101 Something")
+ assert ports_list.tags['ports_list'].selected == 1
+
+ ports_list.try_to_select("missing.zip", "zzz")
+ assert ports_list.tags['ports_list'].selected == 1
+
+
+def test_ports_list_a_opens_port_info(scene_gui, ports_list, monkeypatch):
+ press(scene_gui, monkeypatch, 'A')
+
+ scene = top(scene_gui)
+ assert isinstance(scene, scene_gui.scenes_mod.PortInfoScene)
+ assert scene_gui.get_data('port_info.title') == "-SPROUT-"
+
+
+def test_ports_list_without_harbourmaster(scene_gui):
+ scene_gui.hm = None
+ scene = push(scene_gui, 'ports', scene_gui.scenes_mod.PortsListScene(scene_gui, {'mode': 'install'}))
+
+ assert list_titles(scene) == ["NO PORTS"]
+ assert scene_gui.get_data('port_info.title') == "** NO PORTS FOUND **"
+
+
+def test_manage_ports_empty(scene_gui, monkeypatch):
+ scene = push(scene_gui, 'ports', scene_gui.scenes_mod.PortsListScene(
+ scene_gui, {'mode': 'uninstall', 'base_filters': ['installed']}))
+
+ assert list_titles(scene) == ["NO PORTS"]
+ assert scene_gui.get_data('port_info.description') == "Download ports first."
+
+ ## Nothing to filter: X does nothing, A does nothing.
+ press(scene_gui, monkeypatch, 'X')
+ press(scene_gui, monkeypatch, 'A')
+ assert top(scene_gui) is scene
+
+
+def test_manage_ports_lists_installed(scene_gui, monkeypatch):
+ assert scene_gui.hm.install_port("2048plus.zip") == 0
+ scene_gui.hm.load_ports()
+
+ scene = push(scene_gui, 'ports', scene_gui.scenes_mod.PortsListScene(
+ scene_gui, {'mode': 'uninstall', 'base_filters': ['installed']}))
+ assert list_titles(scene) == ["2048 Plus"]
+
+ press(scene_gui, monkeypatch, 'A')
+ assert isinstance(top(scene_gui), scene_gui.scenes_mod.PortInfoScene)
+
+
+@pytest.fixture
+def filters(scene_gui, ports_list, monkeypatch):
+ press(scene_gui, monkeypatch, 'X')
+ scene = top(scene_gui)
+ assert isinstance(scene, scene_gui.scenes_mod.FiltersScene)
+ return scene
+
+
+def test_filters_options(scene_gui, filters):
+ options = filters.tags['filter_list'].options
+
+ for option in harbourmaster.HM_SORT_ORDER + ['puzzle', 'platformer', 'rtr', '!rtr', 'not installed']:
+ assert option in options, option
+
+ ## No ports: not offered.
+ assert 'rpg' not in options
+ assert 'broken' not in options
+ assert 'clear-filters' not in options
+
+
+def test_filters_genre(scene_gui, filters, ports_list, monkeypatch):
+ choose(scene_gui, monkeypatch, 'puzzle', tag='filter_list')
+
+ assert list_titles(ports_list) == ["2048 Plus"]
+ assert scene_gui.get_data('ports_list.filters') == "puzzle"
+ assert 'clear-filters' in filters.tags['filter_list'].options
+
+ ## Again: off.
+ choose(scene_gui, monkeypatch, 'puzzle', tag='filter_list')
+ assert len(list_titles(ports_list)) == 3
+
+ choose(scene_gui, monkeypatch, 'platformer', tag='filter_list')
+ choose(scene_gui, monkeypatch, 'rtr', tag='filter_list')
+ assert list_titles(ports_list) == ["100 Lil Jumps"]
+
+ choose(scene_gui, monkeypatch, 'clear-filters', tag='filter_list')
+ assert len(list_titles(ports_list)) == 3
+ assert filters.selected_genres == []
+
+
+def test_filters_sorting(scene_gui, filters, ports_list, monkeypatch):
+ ## Pressing the current sort again reverses it.
+ choose(scene_gui, monkeypatch, 'alphabetical', tag='filter_list')
+ assert ports_list.options['sort_by'] == "alphabetical_rev"
+ assert list_titles(ports_list) == ["2048 Plus", "100 Lil Jumps", "-SPROUT-"]
+
+ choose(scene_gui, monkeypatch, 'alphabetical', tag='filter_list')
+ assert ports_list.options['sort_by'] == "alphabetical"
+
+ choose(scene_gui, monkeypatch, 'recently_added', tag='filter_list')
+ assert ports_list.options['sort_by'] == "recently_added"
+ assert len(list_titles(ports_list)) == 3
+
+
+def test_filters_header_rows_do_nothing(scene_gui, filters, monkeypatch):
+ region = filters.tags['filter_list']
+ before = list(region.options)
+ region.selected = region.options.index(None)
+
+ press(scene_gui, monkeypatch, 'A')
+ assert region.options == before
+
+
+@pytest.mark.parametrize("button", ['B', 'X'])
+def test_filters_back(scene_gui, filters, ports_list, monkeypatch, button):
+ press(scene_gui, monkeypatch, button)
+ assert top(scene_gui) is ports_list
+
+
+def test_filters_down_to_nothing(scene_gui, filters, ports_list, monkeypatch):
+ ports_list.options['filters'] = ['puzzle', 'platformer']
+ ports_list.update_ports()
+
+ assert list_titles(ports_list) == ["NO PORTS"]
+ assert scene_gui.get_data('port_info.description') == "Try removing some filters."
+
+
+def test_filters_porters(scene_gui, ports_list, monkeypatch):
+ (scene_gui.hm.cfg_dir / "porters.json").write_text(json.dumps({"saitamasahil": {}, "nobody": {}}))
+ scene_gui.hm._PORTERS = None
+
+ press(scene_gui, monkeypatch, 'X')
+ options = top(scene_gui).tags['filter_list'].options
+
+ assert 'saitamasahil' in options
+ ## No ports by them: not offered.
+ assert 'nobody' not in options
+
+
+################################################################################
+## Featured ports
+@pytest.fixture
+def featured(scene_gui):
+ (scene_gui.hm.cfg_dir / "featured_ports.json").write_text(json.dumps([
+ {"name": "Collections", "type": "category", "description": "Lists of lists", "children": [
+ {"name": "Puzzles", "description": "Thinky", "ports": ["2048plus.zip"]},
+ ]},
+ {"name": "Platformers", "description": "Jumpy", "ports": ["sprout.zip", "100liljumps.zip"]},
+ ]))
+ return push(scene_gui, 'featured-ports', scene_gui.scenes_mod.FeaturedPortsNavigationScene(scene_gui))
+
+
+def test_featured_navigation(scene_gui, featured, monkeypatch):
+ assert featured.tags['option_list'].list == ["Collections", "Platformers"]
+ assert scene_gui.get_data('featured_ports.name') == "Collections"
+
+ select(featured, 1)
+ scene_gui.do_loop(no_delay=True)
+ assert scene_gui.get_data('featured_ports.description') == "Jumpy"
+
+ press(scene_gui, monkeypatch, 'A')
+ ports = top(scene_gui)
+ assert isinstance(ports, scene_gui.scenes_mod.FeaturedPortsScene)
+ assert list_titles(ports) == ["-SPROUT-", "100 Lil Jumps"]
+ assert scene_gui.get_data('ports_list.total_ports') == "2"
+
+ press(scene_gui, monkeypatch, 'A')
+ assert isinstance(top(scene_gui), scene_gui.scenes_mod.PortInfoScene)
+
+ press(scene_gui, monkeypatch, 'B')
+ press(scene_gui, monkeypatch, 'B')
+ assert top(scene_gui) is featured
+
+
+def test_featured_category(scene_gui, featured, monkeypatch):
+ press(scene_gui, monkeypatch, 'A')
+
+ nested = top(scene_gui)
+ assert isinstance(nested, scene_gui.scenes_mod.FeaturedPortsNavigationScene)
+ assert nested.scene_title == "Collections"
+ assert nested.tags['option_list'].list == ["Puzzles"]
+
+ press(scene_gui, monkeypatch, 'A')
+ assert list_titles(top(scene_gui)) == ["2048 Plus"]
+
+ press(scene_gui, monkeypatch, 'B')
+ press(scene_gui, monkeypatch, 'B')
+ assert top(scene_gui) is featured
+
+
+def test_featured_breadcrumbs(scene_gui):
+ scene = push(scene_gui, 'nav', scene_gui.scenes_mod.FeaturedPortsNavigationScene(
+ scene_gui, [{"name": "Deeper", "type": "category", "description": "", "image": "", "children": []}], "Top"))
+ assert scene.scene_title == "Top"
+
+
+################################################################################
+## Options
+@pytest.fixture
+def options(scene_gui, monkeypatch, tmp_path):
+ ## Options that touch PortMaster's own folder use the HarbourMaster's.
+ monkeypatch.setattr(harbourmaster, "HM_TOOLS_DIR", scene_gui.hm.tools_dir)
+ return push(scene_gui, 'option', scene_gui.scenes_mod.OptionScene(scene_gui))
+
+
+def option_text(scene, option):
+ region = scene.tags['option_list']
+ return region.list[region.options.index(option)]
+
+
+def test_options_list(scene_gui, options):
+ ids = options.tags['option_list'].options
+
+ for option in ('select-language', 'select-theme', 'toggle-music', 'toggle-sfx',
+ 'runtime-manager', 'toggle-experimental', 'update-ports', 'update-portmaster', 'release-channel'):
+ assert option in ids, option
+
+ ## The default platform has no gamelist and no secret options yet.
+ for option in ('toggle-gamelist', 'toggle-all', 'delete-config'):
+ assert option not in ids
+
+
+@pytest.mark.parametrize("option, key, label", [
+ ('toggle-experimental', 'show_experimental', "Experimental Ports: "),
+ ('toggle-music', 'music-disabled', "Music: "),
+ ('toggle-sfx', 'sfx-disabled', "Sound FX: "),
+ ])
+def test_options_toggles(scene_gui, options, monkeypatch, option, key, label):
+ before = option_text(options, option)
+ choose(scene_gui, monkeypatch, option)
+
+ after = option_text(options, option)
+ assert after.startswith(label) and after != before
+ assert key in json.loads(scene_gui.hm.cfg_file.read_text())
+
+ choose(scene_gui, monkeypatch, option)
+ assert option_text(options, option) == before
+
+
+def test_options_update_portmaster(scene_gui, options, monkeypatch):
+ scene_gui.hm.cfg_data['update_checked'] = "2026-01-01T00:00:00"
+ choose(scene_gui, monkeypatch, 'update-portmaster')
+
+ assert scene_gui.hm.cfg_data['update_checked'] is None
+ assert scene_gui.events.running is False
+
+
+@pytest.mark.parametrize("current, new", [("stable", "beta"), ("beta", "stable"), ("alpha", "beta")])
+def test_options_release_channel(scene_gui, options, monkeypatch, current, new):
+ scene_gui.hm.cfg_data['release_channel'] = current
+ choose(scene_gui, monkeypatch, 'release-channel')
+
+ assert f"from {current} to {new}" in scene_gui.boxes[-1]
+ assert scene_gui.hm.cfg_data['release_channel'] == new
+ assert scene_gui.hm.cfg_data['change_channel'] is True
+ assert scene_gui.events.running is False
+
+
+def test_options_release_channel_declined(scene_gui, options, monkeypatch):
+ scene_gui.answer = False
+ choose(scene_gui, monkeypatch, 'release-channel')
+
+ assert scene_gui.hm.cfg_data.get('release_channel', 'stable') == 'stable'
+ assert scene_gui.events.running is True
+
+
+def test_options_restore_declined(scene_gui, options, monkeypatch):
+ scene_gui.answer = False
+ installs = []
+ monkeypatch.setattr(scene_gui.hm, "install_port", lambda name: installs.append(name))
+
+ choose(scene_gui, monkeypatch, 'restore-portmaster')
+ assert installs == []
+ assert scene_gui.events.running is True
+
+
+def test_options_restore(scene_gui, options, monkeypatch):
+ installs = []
+ monkeypatch.setattr(scene_gui.hm, "install_port", lambda name: installs.append(name) or 0)
+
+ choose(scene_gui, monkeypatch, 'restore-portmaster')
+ assert installs == ["restore.portmaster.zip"]
+ assert "Restore PortMaster.sh" in scene_gui.boxes[-2]
+ assert scene_gui.events.running is False
+
+
+def test_options_restore_failed(scene_gui, options, monkeypatch):
+ monkeypatch.setattr(scene_gui.hm, "install_port", lambda name: 255)
+
+ choose(scene_gui, monkeypatch, 'restore-portmaster')
+ assert scene_gui.events.running is True
+
+
+@pytest.mark.parametrize("option, scene_name", [
+ ('select-language', 'LanguageScene'),
+ ('select-theme', 'ThemesScene'),
+ ('runtime-manager', 'RuntimesScene'),
+ ])
+def test_options_open_screens(scene_gui, options, monkeypatch, option, scene_name):
+ choose(scene_gui, monkeypatch, option)
+ assert type(top(scene_gui)).__name__ == scene_name
+
+ press(scene_gui, monkeypatch, 'B')
+ assert top(scene_gui) is options
+
+
+def test_options_back(scene_gui, options, monkeypatch):
+ press(scene_gui, monkeypatch, 'B')
+ assert top(scene_gui) is not options
+
+
+def test_options_update_ports(scene_gui, options, monkeypatch):
+ updated = []
+ monkeypatch.setattr(scene_gui, "do_update_ports", lambda: updated.append(True))
+
+ choose(scene_gui, monkeypatch, 'update-ports')
+ assert updated == [True]
+
+
+@pytest.fixture
+def secret_options(scene_gui, monkeypatch):
+ monkeypatch.setattr(harbourmaster, "HM_TOOLS_DIR", scene_gui.hm.tools_dir)
+ scene_gui.hm.cfg_data['konami'] = True
+ return push(scene_gui, 'option', scene_gui.scenes_mod.OptionScene(scene_gui))
+
+
+def test_secret_options_listed(scene_gui, secret_options):
+ ids = secret_options.tags['option_list'].options
+ for option in ('release-alpha', 'toggle-all', 'toggle-cwtbe', 'toggle-debug', 'delete-config', 'delete-runtimes'):
+ assert option in ids, option
+
+
+def test_secret_alpha_hidden_on_alpha(scene_gui, monkeypatch):
+ scene_gui.hm.cfg_data.update({'konami': True, 'release_channel': 'alpha'})
+ scene = push(scene_gui, 'option', scene_gui.scenes_mod.OptionScene(scene_gui))
+ assert 'release-alpha' not in scene.tags['option_list'].options
+
+
+def test_secret_release_alpha(scene_gui, secret_options, monkeypatch):
+ choose(scene_gui, monkeypatch, 'release-alpha')
+
+ assert scene_gui.hm.cfg_data['release_channel'] == 'alpha'
+ assert scene_gui.events.running is False
+
+
+def test_secret_release_alpha_declined(scene_gui, secret_options, monkeypatch):
+ scene_gui.answer = False
+ choose(scene_gui, monkeypatch, 'release-alpha')
+ assert scene_gui.hm.cfg_data.get('release_channel', 'stable') == 'stable'
+
+
+def test_secret_toggle_all(scene_gui, secret_options, monkeypatch):
+ choose(scene_gui, monkeypatch, 'toggle-all')
+ assert scene_gui.hm.cfg_data['show_all'] is True
+ assert option_text(secret_options, 'toggle-all') == "All Ports: Enabled"
+
+
+@pytest.mark.parametrize("option, flag, label", [
+ ('toggle-cwtbe', "cwtbe_flag", "CWTBE Mode: "),
+ ('toggle-debug', "debug_all_the_things_flag", "Debug Mode: "),
+ ])
+def test_secret_flags(scene_gui, secret_options, monkeypatch, option, flag, label):
+ flag_file = scene_gui.hm.tools_dir / "PortMaster" / flag
+
+ choose(scene_gui, monkeypatch, option)
+ assert flag_file.is_file()
+ assert option_text(secret_options, option) == label + "Enabled"
+
+ choose(scene_gui, monkeypatch, option)
+ assert not flag_file.exists()
+ assert option_text(secret_options, option) == label + "Disabled"
+
+
+def test_secret_delete_runtimes(scene_gui, secret_options, monkeypatch):
+ choose(scene_gui, monkeypatch, 'delete-runtimes')
+ assert scene_gui.boxes[-1] == "No runtimes found."
+
+ for name in ("a.squashfs", "b.squashfs"):
+ (scene_gui.hm.libs_dir / name).write_bytes(b"x")
+
+ choose(scene_gui, monkeypatch, 'delete-runtimes')
+ assert scene_gui.boxes[-1] == "Removed 2 runtimes."
+ assert list(scene_gui.hm.libs_dir.glob("*.squashfs")) == []
+
+
+def test_secret_delete_config(scene_gui, secret_options, monkeypatch):
+ choose(scene_gui, monkeypatch, 'delete-config')
+
+ assert not scene_gui.hm.cfg_dir.exists()
+ assert scene_gui.events.running is False
+
+
+def test_secret_delete_config_already_gone(scene_gui, secret_options, monkeypatch):
+ import shutil
+ shutil.rmtree(str(scene_gui.hm.cfg_dir))
+
+ choose(scene_gui, monkeypatch, 'delete-config')
+ assert scene_gui.events.running is False
+
+
+def test_options_gcd_toggle(scene_gui, monkeypatch):
+ backup = scene_gui.hm.tools_dir / "PortMaster" / ".Backup"
+ backup.mkdir(parents=True)
+ (backup / "donottouch.txt").write_text("# Standard\n")
+ (backup / "donottouch_x.txt").write_text("# Xbox 360 Layout\n")
+
+ from harbourmaster.platform import PlatformAmberELEC
+ monkeypatch.setattr(scene_gui.hm, "platform", PlatformAmberELEC(scene_gui.hm))
+ scene = push(scene_gui, 'option', scene_gui.scenes_mod.OptionScene(scene_gui))
+
+ assert option_text(scene, 'toggle-gcd') == "Controller Mode: standard"
+ choose(scene_gui, monkeypatch, 'toggle-gcd')
+ assert option_text(scene, 'toggle-gcd') == "Controller Mode: xbox"
+ choose(scene_gui, monkeypatch, 'toggle-gcd')
+ assert option_text(scene, 'toggle-gcd') == "Controller Mode: standard"
+
+
+def test_options_gamelist(scene_gui, monkeypatch):
+ from harbourmaster.platform import PlatformTesting
+ monkeypatch.setattr(scene_gui.hm, "platform", PlatformTesting(scene_gui.hm))
+ scene = push(scene_gui, 'option', scene_gui.scenes_mod.OptionScene(scene_gui))
+
+ assert option_text(scene, 'toggle-gamelist') == "Metadata Update: Auto"
+ choose(scene_gui, monkeypatch, 'toggle-gamelist')
+ assert option_text(scene, 'toggle-gamelist') == "Metadata Update: Manual"
+ assert scene_gui.hm.cfg_data['gamelist_update'] is False
+
+ updated = []
+ monkeypatch.setattr(scene_gui, "update_gamelist_xml", lambda: updated.append(True))
+ scene_gui.answer = False
+ choose(scene_gui, monkeypatch, 'update-gamelist')
+ assert updated == []
+
+ scene_gui.answer = True
+ choose(scene_gui, monkeypatch, 'update-gamelist')
+ assert updated == [True]
+
+
+def test_options_trimui_port_mode(scene_gui, monkeypatch):
+ moved = []
+ monkeypatch.setattr(scene_gui.hm, "platform_name", "trimui")
+ monkeypatch.setattr(scene_gui.hm.platform, "do_move_ports", lambda: moved.append(True), raising=False)
+ scene = push(scene_gui, 'option', scene_gui.scenes_mod.OptionScene(scene_gui))
+
+ assert option_text(scene, 'trimui-port-mode-toggle') == "Ports Location: Roms section"
+ choose(scene_gui, monkeypatch, 'trimui-port-mode-toggle')
+
+ assert scene_gui.hm.cfg_data['trimui-port-mode'] == 'ports'
+ assert option_text(scene, 'trimui-port-mode-toggle') == "Ports Location: Ports tab"
+ assert moved == [True]
+ ## No restore on TrimUI.
+ assert 'restore-portmaster' not in scene.tags['option_list'].options
+
+
+################################################################################
+## Language
+@pytest.fixture
+def language(scene_gui):
+ return push(scene_gui, 'select-language', scene_gui.scenes_mod.LanguageScene(scene_gui))
+
+
+def test_language_list(scene_gui, language):
+ region = language.tags['option_list']
+ assert any(text.endswith("(Selected)") for text in region.list if isinstance(text, str))
+ assert region.options[-1] == ('back', None)
+
+
+def test_language_back_option(scene_gui, language, monkeypatch):
+ select(language, ('back', None))
+ scene_gui.do_loop(no_delay=True)
+ press(scene_gui, monkeypatch, 'A')
+
+ assert top(scene_gui) is not language
+
+
+def test_language_blank_row(scene_gui, language, monkeypatch):
+ ## The blank row before Back holds None, which couldn't be unpacked. The d-pad
+ ## skips such rows, so this guards against a theme's list that doesn't.
+ region = language.tags['option_list']
+ region.selected = region.options.index(None)
+
+ press(scene_gui, monkeypatch, 'A')
+ assert top(scene_gui) is language
+
+
+def test_language_selected_row_does_nothing(scene_gui, language, monkeypatch):
+ region = language.tags['option_list']
+ region.selected = region.options.index((None, ''))
+
+ press(scene_gui, monkeypatch, 'A')
+ assert top(scene_gui) is language
+
+
+def other_languages(language):
+ return [option for option in language.tags['option_list'].options if isinstance(option, tuple) and option[0] == 'select-language']
+
+
+def test_language_choose(scene_gui, language, monkeypatch):
+ ## Asks, saves it, and quits: it applies at the next start.
+ from pugwash import lang
+ other = [option for option in other_languages(language) if option[1] != lang.DEFAULT_LANG][0]
+
+ choose(scene_gui, monkeypatch, other)
+
+ assert "change language" in scene_gui.boxes[-1]
+ assert scene_gui.hm.cfg_data['language'] == other[1]
+ assert scene_gui.events.running is False
+
+
+def test_language_choose_declined(scene_gui, language, monkeypatch):
+ scene_gui.answer = False
+ choose(scene_gui, monkeypatch, other_languages(language)[0])
+
+ assert 'language' not in scene_gui.hm.cfg_data
+ assert scene_gui.events.running is True
+
+
+def test_language_choose_the_default(scene_gui, monkeypatch):
+ ## The language came from the system, not the config: del raised KeyError.
+ from pugwash import lang
+ monkeypatch.setenv("LANG", "xx_XX")
+ scene_gui.hm.cfg_data.pop('language', None)
+ language = push(scene_gui, 'select-language', scene_gui.scenes_mod.LanguageScene(scene_gui))
+
+ choose(scene_gui, monkeypatch, ('select-language', lang.DEFAULT_LANG))
+
+ assert 'language' not in scene_gui.hm.cfg_data
+ assert scene_gui.events.running is False
+
+
+def test_language_b(scene_gui, language, monkeypatch):
+ press(scene_gui, monkeypatch, 'B')
+ assert top(scene_gui) is not language
+
+
+################################################################################
+## On screen keyboard
+def test_keyboard(scene_gui, monkeypatch):
+ keyboard = push(scene_gui, 'osk', scene_gui.scenes_mod.OnScreenKeyboard(scene_gui))
+ region = keyboard.tags['keyboard']
+ assert region.list[0][1] == ' 1 '
+
+ press(scene_gui, monkeypatch, 'X')
+ assert keyboard.mode == 'upper'
+ assert region.list[0][1] == ' ! '
+
+ press(scene_gui, monkeypatch, 'X')
+ assert keyboard.mode == 'lower'
+
+ region.list_select(3)
+ scene_gui.do_loop(no_delay=True)
+
+ ## Typing isn't hooked up yet: A and START do nothing.
+ press(scene_gui, monkeypatch, 'A')
+ press(scene_gui, monkeypatch, 'START')
+ assert top(scene_gui) is keyboard
+
+ press(scene_gui, monkeypatch, 'B')
+ assert top(scene_gui) is not keyboard
diff --git a/tests/test_gui_screens.py b/tests/test_gui_screens.py
new file mode 100644
index 00000000..a0ed5c91
--- /dev/null
+++ b/tests/test_gui_screens.py
@@ -0,0 +1,577 @@
+# SPDX-License-Identifier: MIT
+#
+# The GUI's other screens a button at a time: startup (disclaimer, unsupported
+# firmware warnings), port info, the runtime manager, themes and colour schemes.
+
+import json
+from pathlib import Path
+
+import pytest
+
+import harbourmaster
+
+from test_gui_scenes import choose, press, push, select, top
+
+
+pytestmark = pytest.mark.sdl
+
+
+@pytest.fixture
+def screen_gui(gui, online_hm, monkeypatch):
+ from pugwash import scenes
+
+ saved_scenes = list(gui.scenes)
+ gui.hm = online_hm
+ gui.boxes = []
+ gui.answer = True
+ gui.cancelled = []
+ gui.scenes_mod = scenes
+
+ def message_box(text, *args, **kwargs):
+ gui.boxes.append(text)
+ return gui.answer
+
+ monkeypatch.setattr(gui, "message_box", message_box)
+ monkeypatch.setattr(gui, "do_cancel", lambda: gui.cancelled.append(True))
+
+ yield gui
+
+ gui.scenes = saved_scenes
+ gui.events.running = True
+ gui.hm = None
+
+
+def bar(scene):
+ return [item[1] if isinstance(item, tuple) else item for item in scene.tags['button_bar'].bar]
+
+
+################################################################################
+## Startup warnings
+class Root:
+ def __init__(self, tmp_path):
+ self.root = tmp_path / "root"
+ self.root.mkdir()
+
+ def __call__(self, *args):
+ path = Path(*args)
+ return self.root / path.relative_to("/") if path.is_absolute() else path
+
+ def write(self, absolute, data=""):
+ path = self.root / absolute.lstrip("/")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if isinstance(data, bytes):
+ path.write_bytes(data)
+ else:
+ path.write_text(data)
+
+
+@pytest.fixture
+def device_root(pugwash, tmp_path, monkeypatch):
+ from pugwash.scenes import startup
+ root = Root(tmp_path)
+ monkeypatch.setattr(startup, "Path", root)
+ return root
+
+
+def warnings_for(ram=4096):
+ from pugwash.scenes.startup import get_startup_warnings
+ return [keyword for keyword, reason, suggestion in get_startup_warnings({'ram': ram})]
+
+
+def test_no_warnings(device_root):
+ assert warnings_for() == []
+
+
+@pytest.mark.parametrize("files, expected", [
+ ({"/mnt/SDCARD/spruce/x": ""}, ["spruce"]),
+ ({"/mnt/SDCARD/autorun.inf": "[autorun]\nlabel=CrossMix-OS\n"}, ["crossmix"]),
+ ({"/mnt/SDCARD/.system/version.txt": "NextUI 6.0\n"}, ["nextui"]),
+ ({"/etc/trimui_device.txt": "tg5040"}, ["trimui"]),
+ ## A known firmware is named, not the generic TrimUI one.
+ ({"/etc/trimui_device.txt": "tg5040", "/mnt/SDCARD/.system/version.txt": "NextUI"}, ["nextui"]),
+ ({"/mnt/SDCARD/autorun.inf": b"\xff\xfe binary"}, []),
+ ({"/mnt/SDCARD/autorun.inf": "other"}, []),
+ ])
+def test_firmware_warnings(device_root, files, expected):
+ for path, data in files.items():
+ device_root.write(path, data)
+
+ assert warnings_for() == expected
+
+
+def test_low_ram_warning(device_root):
+ assert warnings_for(ram=512) == ["lowram"]
+ assert warnings_for(ram=1024) == []
+
+
+def test_next_warning_time(pugwash):
+ import datetime
+ from pugwash.scenes.startup import get_next_warning_time
+
+ days = (datetime.date.fromisoformat(get_next_warning_time()) - datetime.date.today()).days
+ assert days in (5, 7, 11, 13)
+
+
+@pytest.fixture
+def waited(screen_gui, monkeypatch):
+ """The disclaimer and warning timers have run out."""
+ monkeypatch.setattr(screen_gui.timers, "since", lambda name: 60_000)
+ return screen_gui
+
+
+def root_scene(gui):
+ return gui.scenes[0][1][-1]
+
+
+@pytest.mark.parametrize("cfg, warnings, expected", [
+ ({}, [], "DisclaimerScene"),
+ ({'disclaimer': True}, [], "MainMenuScene"),
+ ({'disclaimer': True}, [('lowram', "Low RAM", None)], "StartupWarningScene"),
+ ({'disclaimer': True, 'warning_time': "2999-01-01"}, [('lowram', "Low RAM", None)], "MainMenuScene"),
+ ({'disclaimer': True, 'warning_time': "2000-01-01"}, [('lowram', "Low RAM", None)], "StartupWarningScene"),
+ ])
+def test_temp_menu_routes(screen_gui, monkeypatch, cfg, warnings, expected):
+ from pugwash.scenes import startup
+ monkeypatch.setattr(startup, "get_startup_warnings", lambda info: warnings)
+ for key in ('disclaimer', 'warning_time'):
+ screen_gui.hm.cfg_data.pop(key, None)
+ screen_gui.hm.cfg_data.update(cfg)
+
+ screen_gui.scenes = [('root', [startup.TempMenuScene(screen_gui)])]
+ screen_gui.do_loop(no_delay=True)
+
+ assert type(root_scene(screen_gui)).__name__ == expected
+
+
+@pytest.mark.parametrize("warnings, expected", [([], "MainMenuScene"), ([('lowram', "Low RAM", None)], "StartupWarningScene")])
+def test_disclaimer_accept(waited, monkeypatch, warnings, expected):
+ from pugwash.scenes import startup
+ monkeypatch.setattr(startup, "get_startup_warnings", lambda info: warnings)
+ waited.scenes = [('root', [startup.DisclaimerScene(waited)])]
+ root_scene(waited).do_draw()
+
+ assert "Accept" in bar(root_scene(waited))
+
+ press(waited, monkeypatch, 'A')
+ assert waited.hm.cfg_data['disclaimer'] is True
+ assert type(root_scene(waited)).__name__ == expected
+
+
+def test_disclaimer_must_wait(screen_gui, monkeypatch):
+ from pugwash.scenes import startup
+ monkeypatch.setattr(screen_gui.timers, "since", lambda name: 3_000)
+ screen_gui.scenes = [('root', [startup.DisclaimerScene(screen_gui)])]
+ root_scene(screen_gui).do_draw()
+
+ assert bar(root_scene(screen_gui)) == ["Wait 12 seconds"]
+
+ press(screen_gui, monkeypatch, 'A')
+ assert 'disclaimer' not in screen_gui.hm.cfg_data
+ assert type(root_scene(screen_gui)).__name__ == "DisclaimerScene"
+
+
+@pytest.mark.parametrize("answer", [True, False])
+def test_disclaimer_quit(waited, monkeypatch, answer):
+ from pugwash.scenes import startup
+ waited.answer = answer
+ waited.scenes = [('root', [startup.DisclaimerScene(waited)])]
+
+ press(waited, monkeypatch, 'B')
+ assert waited.cancelled == ([True] if answer else [])
+
+
+def test_warning_screen(waited, monkeypatch):
+ from pugwash.scenes import startup
+ warnings = [
+ ('spruce', "SpruceOS is not supported by PortMaster.", "Switch."),
+ ('lowram', "Low RAM.", None),
+ ('other', "Other.", "Switch."),
+ ]
+ waited.scenes = [('root', [startup.StartupWarningScene(waited, warnings)])]
+ root_scene(waited).do_draw()
+
+ text = root_scene(waited).tags['disclaimer_text'].text
+ ## Each reason, then each suggestion once.
+ assert text == "SpruceOS is not supported by PortMaster.\nLow RAM.\nOther.\nMany ports will not work.\n\nSwitch."
+ assert "I Understand" in bar(root_scene(waited))
+
+ press(waited, monkeypatch, 'A')
+ assert waited.hm.cfg_data['warning_time'] > "2000"
+ assert type(root_scene(waited)).__name__ == "MainMenuScene"
+
+
+def test_warning_screen_waits(screen_gui, monkeypatch):
+ from pugwash.scenes import startup
+ monkeypatch.setattr(screen_gui.timers, "since", lambda name: 0)
+ screen_gui.scenes = [('root', [startup.StartupWarningScene(screen_gui, [('lowram', "Low RAM.", None)])])]
+ root_scene(screen_gui).do_draw()
+
+ assert bar(root_scene(screen_gui)) == ["Wait 10 seconds"]
+ press(screen_gui, monkeypatch, 'A')
+ assert type(root_scene(screen_gui)).__name__ == "StartupWarningScene"
+
+
+@pytest.mark.parametrize("answer", [True, False])
+def test_warning_screen_quit(waited, monkeypatch, answer):
+ from pugwash.scenes import startup
+ waited.answer = answer
+ waited.scenes = [('root', [startup.StartupWarningScene(waited, [('lowram', "Low RAM.", None)])])]
+
+ press(waited, monkeypatch, 'B')
+ assert waited.cancelled == ([True] if answer else [])
+
+
+################################################################################
+## Port info
+@pytest.fixture
+def info(screen_gui):
+ scenes = screen_gui.scenes_mod
+ list_scene = push(screen_gui, 'ports', scenes.PortsListScene(screen_gui, {'mode': 'install', 'name': None}))
+ scene = push(screen_gui, 'port_info', scenes.PortInfoScene(screen_gui, "2048plus.zip", 'install', list_scene))
+ scene.list_scene = list_scene
+ return scene
+
+
+def test_port_info_install(screen_gui, info, monkeypatch):
+ assert screen_gui.get_data('port_info.title') == "2048 Plus"
+ assert "Install" in bar(info) and "Uninstall" not in bar(info)
+
+ installs = []
+ monkeypatch.setattr(screen_gui, "do_install", lambda name: installs.append(name))
+ press(screen_gui, monkeypatch, 'A')
+
+ assert installs == ["2048plus.zip"]
+ assert top(screen_gui) is info.list_scene
+
+
+def test_port_info_installed(screen_gui, monkeypatch):
+ assert screen_gui.hm.install_port("2048plus.zip") == 0
+ screen_gui.hm.load_ports()
+ scenes = screen_gui.scenes_mod
+ list_scene = push(screen_gui, 'ports', scenes.PortsListScene(screen_gui, {'mode': 'uninstall', 'base_filters': ['installed']}))
+ info = push(screen_gui, 'port_info', scenes.PortInfoScene(screen_gui, "2048plus.zip", 'uninstall', list_scene))
+
+ assert "Reinstall" in bar(info) and "Uninstall" in bar(info)
+
+ uninstalls = []
+ monkeypatch.setattr(screen_gui, "do_uninstall", lambda name: uninstalls.append(name))
+
+ screen_gui.answer = False
+ press(screen_gui, monkeypatch, 'Y')
+ assert uninstalls == []
+ assert "uninstall 2048 Plus" in screen_gui.boxes[-1]
+
+ screen_gui.answer = True
+ press(screen_gui, monkeypatch, 'Y')
+ assert uninstalls == ["2048plus.zip"]
+ assert top(screen_gui) is list_scene
+
+
+def test_port_info_y_when_not_installed(screen_gui, info, monkeypatch):
+ press(screen_gui, monkeypatch, 'Y')
+ assert screen_gui.boxes == []
+ assert top(screen_gui) is info
+
+
+def test_port_info_back(screen_gui, info, monkeypatch):
+ press(screen_gui, monkeypatch, 'B')
+ assert top(screen_gui) is info.list_scene
+
+
+def test_port_info_next_and_previous(screen_gui, info, monkeypatch):
+ ## The default theme has a popup: LEFT and RIGHT move between ports.
+ buttons = info.config['buttons']
+ next_button = [button for button, action in buttons.items() if action == 'next_port'][0]
+ prev_button = [button for button, action in buttons.items() if action == 'prev_port'][0]
+
+ info.list_scene.tags['ports_list'].list_select(2)
+ info.list_scene.last_port = 2
+
+ press(screen_gui, monkeypatch, next_button)
+ assert info.port_name == "sprout.zip"
+ assert screen_gui.get_data('port_info.title') == "-SPROUT-"
+
+ press(screen_gui, monkeypatch, prev_button)
+ assert info.port_name == "2048plus.zip"
+
+
+@pytest.fixture
+def popup_theme(screen_gui, monkeypatch):
+ """A theme with the port info popup (the default theme has none)."""
+ monkeypatch.setitem(screen_gui.theme_data, 'port_info_popup', dict(screen_gui.theme_data['port_info']))
+ return screen_gui
+
+
+def popup_info(gui, port_name):
+ scenes = gui.scenes_mod
+ list_scene = push(gui, 'ports', scenes.PortsListScene(gui, {'mode': 'install', 'name': None}))
+ info = push(gui, 'port_info', scenes.PortInfoScene(gui, port_name, 'install', list_scene))
+ info.list_scene = list_scene
+ return info
+
+
+def test_port_info_popup(popup_theme, monkeypatch):
+ screen_gui = popup_theme
+ info = popup_info(screen_gui, "2048plus.zip")
+ assert "Show Info" in bar(info)
+ assert info.config['buttons']['LEFT'] == 'prev_port'
+
+ press(screen_gui, monkeypatch, 'UP')
+ popup = top(screen_gui)
+ assert isinstance(popup, screen_gui.scenes_mod.PortInfoPopup)
+ assert info.popup_shown
+ assert "Hide Info" in bar(popup)
+
+ ## Only one at a time.
+ info.do_update(type("Events", (), {"was_pressed": lambda self, b: b == 'UP', "any_pressed": lambda self: True})())
+ assert top(screen_gui) is popup
+
+ press(screen_gui, monkeypatch, 'DOWN')
+ assert top(screen_gui) is info
+ assert not info.popup_shown
+
+
+
+
+def test_port_info_popup_follows_the_port(popup_theme, monkeypatch):
+ screen_gui = popup_theme
+ popup_info(screen_gui, "2048plus.zip")
+ press(screen_gui, monkeypatch, 'UP')
+ popup = top(screen_gui)
+ screen_gui.set_data("port_info.name", "something else")
+ screen_gui.do_loop(no_delay=True)
+
+ assert popup.port_name == "something else"
+
+
+
+
+def test_port_info_without_harbourmaster(screen_gui, info):
+ screen_gui.hm = None
+ info.ready = False
+ info.update_port()
+ assert info.ready is False
+
+
+################################################################################
+## Runtime manager
+@pytest.fixture
+def runtimes(screen_gui):
+ return push(screen_gui, 'runtime-manager', screen_gui.scenes_mod.RuntimesScene(screen_gui))
+
+
+def test_runtime_list(screen_gui, runtimes):
+ options = runtimes.tags['runtime_list'].options
+
+ ## aarch64 builds only, by name, then "Download All".
+ assert [option for option in options if option] == ['ags_3.6.squashfs', 'dotnet-8.0.12.squashfs', 'gmtoolkit.squashfs', 'all']
+
+ select(runtimes, 'dotnet-8.0.12.squashfs', tag='runtime_list')
+ screen_gui.do_loop(no_delay=True)
+ assert screen_gui.get_data('runtime_info.status') == "Not Installed"
+ assert screen_gui.get_data('runtime_info.disk_size') == ""
+
+
+def test_runtime_selection_details(screen_gui, monkeypatch):
+ (screen_gui.hm.libs_dir / "gmtoolkit.squashfs").write_bytes(b"x" * 2048)
+ scene = push(screen_gui, 'runtime-manager', screen_gui.scenes_mod.RuntimesScene(screen_gui))
+
+ select(scene, 'gmtoolkit.squashfs', tag='runtime_list')
+ screen_gui.do_loop(no_delay=True)
+
+ assert screen_gui.get_data('runtime_info.name') == "GMToolkit"
+ assert screen_gui.get_data('runtime_info.status') == "Installed"
+ assert screen_gui.get_data('runtime_info.disk_size') == "2.00 KB"
+ assert "Uninstall" in bar(scene)
+
+
+def test_runtime_check(screen_gui, runtimes, monkeypatch):
+ checked = []
+
+ def check(name, in_install=False):
+ checked.append((name, in_install))
+ (screen_gui.hm.libs_dir / name).write_bytes(b"data")
+
+ monkeypatch.setattr(screen_gui, "do_runtime_check", check)
+ choose(screen_gui, monkeypatch, 'dotnet-8.0.12.squashfs', tag='runtime_list')
+
+ assert checked == [('dotnet-8.0.12.squashfs', False)]
+ assert runtimes.runtimes['dotnet-8.0.12.squashfs']['installed'] is True
+
+
+def test_runtime_check_failed(screen_gui, runtimes, monkeypatch):
+ monkeypatch.setattr(screen_gui, "do_runtime_check", lambda name, in_install=False: None)
+ choose(screen_gui, monkeypatch, 'dotnet-8.0.12.squashfs', tag='runtime_list')
+
+ assert runtimes.runtimes['dotnet-8.0.12.squashfs']['installed'] is False
+
+
+def test_runtime_download_all(screen_gui, runtimes, monkeypatch):
+ checked = []
+
+ def check(name, in_install=False):
+ checked.append(name)
+ if name != 'ags_3.6.squashfs':
+ (screen_gui.hm.libs_dir / name).write_bytes(b"data")
+
+ monkeypatch.setattr(screen_gui, "do_runtime_check", check)
+
+ screen_gui.answer = False
+ choose(screen_gui, monkeypatch, 'all', tag='runtime_list')
+ assert checked == []
+
+ screen_gui.answer = True
+ choose(screen_gui, monkeypatch, 'all', tag='runtime_list')
+ assert sorted(checked) == ['ags_3.6.squashfs', 'dotnet-8.0.12.squashfs', 'gmtoolkit.squashfs']
+ assert runtimes.runtimes['all']['installed'] is False
+
+
+def test_runtime_uninstall(screen_gui, monkeypatch):
+ runtime = screen_gui.hm.libs_dir / "gmtoolkit.squashfs"
+ runtime.write_bytes(b"x")
+ scene = push(screen_gui, 'runtime-manager', screen_gui.scenes_mod.RuntimesScene(screen_gui))
+
+ select(scene, 'gmtoolkit.squashfs', tag='runtime_list')
+ screen_gui.do_loop(no_delay=True)
+ press(screen_gui, monkeypatch, 'Y')
+
+ assert not runtime.exists()
+ assert screen_gui.boxes[-1] == "Deleted runtime GMToolkit"
+
+ ## Not installed: Y does nothing.
+ press(screen_gui, monkeypatch, 'Y')
+ assert len(screen_gui.boxes) == 1
+
+
+def test_runtime_manager_shows_ports_using_them(screen_gui, monkeypatch):
+ from conftest import port_files_with, write_port_zip
+ zip_file = write_port_zip(screen_gui.hm.temp_dir / "rt.zip", port_files_with(name="rt", attr={"runtime": ["gmtoolkit.squashfs"], "title": "RT Port"}))
+ monkeypatch.setattr(screen_gui.hm, "check_runtime", lambda *args, **kwargs: 0)
+ assert screen_gui.hm.install_port(str(zip_file)) == 0
+ screen_gui.hm.load_ports()
+ screen_gui.hm._port_attrs_updated = True
+
+ scene = push(screen_gui, 'runtime-manager', screen_gui.scenes_mod.RuntimesScene(screen_gui))
+ select(scene, 'gmtoolkit.squashfs', tag='runtime_list')
+ screen_gui.do_loop(no_delay=True)
+
+ assert screen_gui.get_data('runtime_info.in_use') == "Used"
+ assert screen_gui.get_data('runtime_info.ports') == "RT Port"
+
+
+def test_runtime_manager_back(screen_gui, runtimes, monkeypatch):
+ press(screen_gui, monkeypatch, 'B')
+ assert top(screen_gui) is not runtimes
+
+
+################################################################################
+## Themes
+@pytest.fixture
+def themes(screen_gui, monkeypatch):
+ monkeypatch.setattr(screen_gui.hm, "config", {**screen_gui.hm.config, 'offline': True, 'no-check': True})
+ monkeypatch.setattr(screen_gui, "theme_downloader", None)
+ return push(screen_gui, 'select-theme', screen_gui.scenes_mod.ThemesScene(screen_gui))
+
+
+def test_themes_list(screen_gui, themes):
+ region = themes.tags['themes_list']
+ assert 'default_theme' in region.options
+ assert any("(Selected)" in text for text in region.list)
+ assert "Select" in bar(themes)
+
+
+def test_themes_select(screen_gui, themes, monkeypatch):
+ ## Asks, saves it, and quits: it applies at the next start.
+ screen_gui.hm.cfg_data['theme-scheme'] = "Night"
+ choose(screen_gui, monkeypatch, 'default_theme', tag='themes_list')
+
+ assert "change theme" in screen_gui.boxes[-1]
+ assert screen_gui.hm.cfg_data['theme'] == 'default_theme'
+ assert screen_gui.hm.cfg_data['theme-scheme'] is None
+ assert screen_gui.events.running is False
+
+
+def test_themes_select_declined(screen_gui, themes, monkeypatch):
+ screen_gui.answer = False
+ screen_gui.hm.cfg_data['theme-scheme'] = "Night"
+ choose(screen_gui, monkeypatch, 'default_theme', tag='themes_list')
+
+ assert screen_gui.hm.cfg_data['theme-scheme'] == "Night"
+ assert screen_gui.events.running is True
+
+
+def test_themes_not_installed_cant_be_selected(screen_gui, themes, monkeypatch):
+ themes.themes['remote_theme'] = dict(themes.themes['default_theme'], name="Remote", status="Not Installed", url="https://example.com/remote.theme.zip")
+ ## Still listed after the download reloads the list.
+ listed = dict(themes.themes)
+ monkeypatch.setattr(screen_gui.themes, "get_themes_list", lambda *args: listed)
+ themes.tags['themes_list'].add_option('remote_theme', "Remote")
+
+ installs = []
+ monkeypatch.setattr(screen_gui, "do_install", lambda name, url: installs.append((name, url)))
+
+ select(themes, 'remote_theme', tag='themes_list')
+ screen_gui.do_loop(no_delay=True)
+ assert "Download" in bar(themes) and "Select" not in bar(themes)
+
+ press(screen_gui, monkeypatch, 'A')
+ assert screen_gui.boxes == []
+
+ press(screen_gui, monkeypatch, 'X')
+ assert installs == [("Remote", "https://example.com/remote.theme.zip.md5")]
+
+
+def test_themes_x_without_a_url(screen_gui, themes, monkeypatch):
+ installs = []
+ monkeypatch.setattr(screen_gui, "do_install", lambda name, url: installs.append(name))
+
+ press(screen_gui, monkeypatch, 'X')
+ assert installs == []
+
+
+def test_themes_back(screen_gui, themes, monkeypatch):
+ press(screen_gui, monkeypatch, 'B')
+ assert top(screen_gui) is not themes
+
+
+@pytest.fixture
+def schemes(screen_gui, monkeypatch):
+ schemes = screen_gui.themes.get_theme_schemes_list()
+ if len(schemes) < 2:
+ pytest.skip("the default theme has one scheme")
+
+ return push(screen_gui, 'select-scheme', screen_gui.scenes_mod.ThemeSchemeScene(screen_gui))
+
+
+def test_schemes_list(screen_gui, schemes):
+ region = schemes.tags['option_list']
+ assert sum(1 for text in region.list if "(Selected)" in str(text)) == 1
+ assert region.options[-1] == ('back', None)
+
+
+def test_schemes_choose(screen_gui, schemes, monkeypatch):
+ other = [option for option in schemes.tags['option_list'].options if isinstance(option, tuple) and option[0] == 'select-scheme'][0]
+
+ choose(screen_gui, monkeypatch, other)
+
+ assert screen_gui.hm.cfg_data['theme-scheme'] == other[1]
+ assert screen_gui.events.running is False
+
+
+def test_schemes_back_and_blank(screen_gui, schemes, monkeypatch):
+ region = schemes.tags['option_list']
+ region.selected = region.options.index(None)
+ press(screen_gui, monkeypatch, 'A')
+ assert top(screen_gui) is schemes
+
+ region.selected = region.options.index((None, ''))
+ press(screen_gui, monkeypatch, 'A')
+ assert top(screen_gui) is schemes
+
+ choose(screen_gui, monkeypatch, ('back', None))
+ assert top(screen_gui) is not schemes
+
+
+def test_schemes_b(screen_gui, schemes, monkeypatch):
+ press(screen_gui, monkeypatch, 'B')
+ assert top(screen_gui) is not schemes
diff --git a/tests/test_hardware_edges.py b/tests/test_hardware_edges.py
new file mode 100644
index 00000000..116be37f
--- /dev/null
+++ b/tests/test_hardware_edges.py
@@ -0,0 +1,271 @@
+# SPDX-License-Identifier: MIT
+#
+# hardware.py's helpers and its quick CFW/device probe, against a fake device root.
+
+from pathlib import Path
+
+import pytest
+
+from harbourmaster import hardware
+
+
+class Root:
+ def __init__(self, tmp_path):
+ self.base = tmp_path
+ self.root = tmp_path / "root"
+ self.root.mkdir()
+
+ def __call__(self, *args):
+ path = Path(*args)
+ if path.is_absolute() and self.base not in path.parents:
+ return self.root / path.relative_to("/")
+ return path
+
+ def home(self):
+ return self.root / "home"
+
+ def write(self, absolute, text=""):
+ path = self.root / absolute.lstrip("/")
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+ return path
+
+
+@pytest.fixture
+def root(tmp_path, monkeypatch):
+ fake = Root(tmp_path)
+ monkeypatch.setattr(hardware, "Path", fake)
+ monkeypatch.delenv("FLATPAK_ID", raising=False)
+ return fake
+
+
+def probe(tmp_path):
+ return hardware.HardwareDetector(tmp_path)._quick_probe_identity()
+
+
+@pytest.mark.parametrize("marker, cfw", [
+ ("/app/bin/retrodeck", "retrodeck"),
+ ("/etc/rocknix-release", "rocknix"),
+ ("/storage/.config/rocknix", "rocknix"),
+ ("/etc/jelos-release", "jelos"),
+ ("/opt/muos/config/system/version", "muos"),
+ ("/boot/boot/knulli.board", "knulli"),
+ ("/etc/arkos_version", "arkos"),
+ ("/etc/darkos_version", "arkos"),
+ ])
+def test_probe_cfw(root, tmp_path, marker, cfw):
+ root.write(marker, "board\n")
+ assert probe(tmp_path)[0] == cfw
+
+
+def test_probe_retrodeck_flatpak(root, tmp_path, monkeypatch):
+ monkeypatch.setenv("FLATPAK_ID", "net.retrodeck.retrodeck")
+ assert probe(tmp_path)[0] == "retrodeck"
+
+
+@pytest.mark.parametrize("text, cfw", [
+ ("title=dArkOS\n", "darkos"),
+ ("title=Thera\n", "thera"),
+ ("title=ArkOS (wummle)\n", "arkos"),
+ ("title=Something else\n", "unknown"),
+ ])
+def test_probe_plymouth(root, tmp_path, text, cfw):
+ root.write("/usr/share/plymouth/themes/text.plymouth", text)
+ assert probe(tmp_path)[0] == cfw
+
+
+def test_probe_nothing(root, tmp_path):
+ assert probe(tmp_path) == ("unknown", "unknown")
+
+
+def test_probe_device_name_order(root, tmp_path):
+ root.write("/proc/device-tree/model", "Generic Board\x00")
+ assert probe(tmp_path)[1] == "generic_board"
+
+ root.write("/etc/board", "\n")
+ ## An empty first line is skipped.
+ assert probe(tmp_path)[1] == "generic_board"
+
+ root.write("/home/.config/.DEVICE", "RG353V\nextra\n")
+ assert probe(tmp_path)[1] == "rg353v"
+
+ root.write("/opt/muos/device/config/board/name", "rg-vita-pro")
+ assert probe(tmp_path)[1] == "rg-vita-pro"
+
+
+def test_probe_dmi_product_name(root, tmp_path):
+ root.write("/sys/devices/virtual/dmi/id/product_name", "Steam Deck\n")
+ assert probe(tmp_path)[1] == "steam_deck"
+
+
+def test_probe_names_are_file_safe(root, tmp_path):
+ root.write("/etc/board", "Debian GNU/Linux\n")
+ assert probe(tmp_path)[1] == "debian_gnu_linux"
+
+
+################################################################################
+## Helpers
+@pytest.mark.parametrize("value, expected", [
+ (None, ""),
+ ('"quoted"', "quoted"),
+ ("'single' \r\n", "single"),
+ ("nul\x00l", "null"),
+ (5, "5"),
+ ])
+def test_clean_str(value, expected):
+ assert hardware._clean_str(value) == expected
+
+
+@pytest.mark.parametrize("value, expected", [
+ (None, 7),
+ ("", 7),
+ ('"12"', 12),
+ ("twelve", 7),
+ ("1.5", 7),
+ (3, 3),
+ ])
+def test_safe_int(value, expected):
+ assert hardware._safe_int(value, 7) == expected
+
+
+@pytest.mark.parametrize("text, expected", [
+ ('export DEVICE_INFO_VERSION="0.2.6"\n', "0.2.6"),
+ (' DEVICE_INFO_VERSION=0.3.0\n', "0.3.0"),
+ ('# nothing\nexport OTHER=1\n', None),
+ ('', None),
+ ])
+def test_script_version(tmp_path, text, expected):
+ script = tmp_path / "device_info.txt"
+ script.write_text(text)
+ assert hardware._script_version(script) == expected
+
+
+def test_script_version_missing(tmp_path):
+ assert hardware._script_version(tmp_path / "missing.txt") is None
+
+
+def test_read_env_file_unreadable(tmp_path):
+ assert hardware._read_env_file(tmp_path) == {}
+
+
+def test_find_control_dir(monkeypatch, tmp_path):
+ monkeypatch.setenv("controlfolder", f'"{tmp_path}"')
+ assert hardware._find_control_dir() == tmp_path
+
+ monkeypatch.delenv("controlfolder")
+ monkeypatch.setenv("PORTMASTER_HOME", str(tmp_path / "pm"))
+ assert hardware._find_control_dir() == tmp_path / "pm"
+
+ monkeypatch.delenv("PORTMASTER_HOME")
+ ## From a checkout: the PortMaster folder this file is in.
+ assert hardware._find_control_dir().name == "PortMaster"
+
+
+@pytest.mark.parametrize("script_name", ["device_info.sh", "PortMaster/device_info.txt"])
+def test_device_info_script_names(tmp_path, script_name):
+ path = tmp_path / script_name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text("x")
+
+ assert hardware.HardwareDetector(tmp_path)._device_info_script() == path
+
+
+def test_no_device_info_script(tmp_path):
+ assert hardware.HardwareDetector(tmp_path)._device_info_script() is None
+
+
+################################################################################
+## get_info's details
+def _no_env(monkeypatch):
+ for key in ("DEVICE_NAME", "DEVICE_CPU", "CFW_NAME", "DISPLAY_WIDTH", "DISPLAY_HEIGHT", "DEVICE_ARCH",
+ "DEVICE_CAPABILITIES", "DEVICE_RAM", "CFW_GLIBC", "ANALOG_STICKS", "DEVICE_INFO_VERSION", "CFW_VERSION"):
+ monkeypatch.delenv(key, raising=False)
+
+
+def write_env(folder, text, name="device_info_x_y.env"):
+ (folder / name).write_text(text + "padding=" + "x" * 40 + "\n")
+
+
+def test_get_info_from_cache_details(tmp_path, monkeypatch):
+ _no_env(monkeypatch)
+ write_env(tmp_path, (
+ "CFW_NAME=ROCKNIX\nCFW_VERSION=20250517\nDEVICE_NAME=Retroid Pocket 5\nDEVICE_SLUG=rp5\n"
+ "DISPLAY_WIDTH=1920\nDISPLAY_HEIGHT=1080\nDEVICE_RAM_MB=8192\nANALOG_STICKS=2\n"
+ "DEVICE_TOUCH=y\nDEVICE_HAS_RUMBLE=y\nDEVICE_ARCH=aarch64\nCFW_GLIBC=238\n"
+ "DEVICE_CAPABILITIES=aarch64 1920x1080 power\n"))
+
+ info = hardware.HardwareDetector(tmp_path).get_info()
+
+ assert info['name'] == "rocknix"
+ assert info['device'] == "rp5"
+ assert info['resolution'] == (1920, 1080)
+ assert info['ram'] == 8192
+ assert info['glibc'] == "2.38"
+ assert info['touch'] == "Y" and info['rumble'] == "Y"
+ assert info['capabilities'] == ["aarch64", "1920x1080", "power"]
+
+
+def test_get_info_bad_numbers_use_defaults(tmp_path, monkeypatch):
+ _no_env(monkeypatch)
+ write_env(tmp_path, "CFW_NAME=x\nDISPLAY_WIDTH=wide\nDISPLAY_HEIGHT=\nDEVICE_RAM=lots\nANALOG_STICKS=two\n")
+
+ info = hardware.HardwareDetector(tmp_path).get_info()
+
+ assert info['resolution'] == (640, 480)
+ assert info['ram'] == 1024
+ assert info['analogsticks'] == 0
+
+
+def test_get_info_force_refresh_skips_the_memo(tmp_path, monkeypatch):
+ _no_env(monkeypatch)
+ write_env(tmp_path, "CFW_NAME=first\n")
+ first = hardware.HardwareDetector(tmp_path).get_info()
+
+ write_env(tmp_path, "CFW_NAME=second\n")
+ assert hardware.HardwareDetector(tmp_path).get_info() is first
+
+ monkeypatch.setattr(hardware.subprocess, "run", lambda *args, **kwargs: None)
+ assert hardware.HardwareDetector(tmp_path).get_info(force_refresh=True)['name'] == "unknown"
+
+
+def test_get_info_probe_error_is_survived(tmp_path, monkeypatch):
+ _no_env(monkeypatch)
+ (tmp_path / "device_info.txt").write_text('export DEVICE_INFO_VERSION="0.2.6"\n')
+
+ def fail(*args, **kwargs):
+ raise OSError("no bash")
+
+ monkeypatch.setattr(hardware.subprocess, "run", fail)
+ info = hardware.HardwareDetector(tmp_path).get_info()
+
+ assert info['name'] == "unknown"
+ assert info['resolution'] == (640, 480)
+
+
+def test_get_info_probe_writes_another_name(tmp_path, monkeypatch):
+ ## The script names its cache from what it found, not what the quick probe guessed.
+ _no_env(monkeypatch)
+ (tmp_path / "device_info.txt").write_text('export DEVICE_INFO_VERSION="0.2.6"\n')
+
+ def probe(*args, **kwargs):
+ write_env(tmp_path, 'DEVICE_INFO_VERSION="0.2.6"\nCFW_NAME=Found\n', name="device_info_found_it.env")
+
+ monkeypatch.setattr(hardware.subprocess, "run", probe)
+ assert hardware.HardwareDetector(tmp_path).get_info()['name'] == "found"
+
+
+def test_tiny_cache_is_ignored(tmp_path, monkeypatch):
+ _no_env(monkeypatch)
+ (tmp_path / "device_info_x_y.env").write_text("CFW_NAME=x\n")
+ monkeypatch.setattr(hardware.subprocess, "run", lambda *args, **kwargs: None)
+
+ assert hardware.HardwareDetector(tmp_path).get_info()['name'] == "unknown"
+
+
+def test_api_shims(monkeypatch):
+ assert hardware.hardware_info() == hardware.device_info()
+ info = hardware.expand_info(None)
+ assert info == hardware.device_info() and info is not hardware.device_info()
+
+ expanded = hardware.expand_info({"name": "x"}, override_ram=4096)
+ assert expanded['ram'] == 4096
diff --git a/tests/test_hm_info.py b/tests/test_hm_info.py
new file mode 100644
index 00000000..196ed365
--- /dev/null
+++ b/tests/test_hm_info.py
@@ -0,0 +1,355 @@
+# SPDX-License-Identifier: MIT
+#
+# HarbourMaster.load_info(): the PortMaster-Info files (ports_info.json, porters.json,
+# port stats, featured ports), fetched through the GitHub tree API.
+
+import io
+import json
+import zipfile
+
+import pytest
+
+from harbourmaster import hm_info
+from harbourmaster.util import calculate_git_blob_sha_data, calculate_git_blob_sha_file
+
+
+class FakeInfoRepo:
+ """PortMaster-Info as the tree API and raw downloads see it."""
+ def __init__(self, monkeypatch, temp_dir):
+ self.files = {}
+ self.tree_text = None
+ self.fetched = []
+ self.downloaded = []
+ self.corrupt = set()
+ self.temp_dir = temp_dir
+
+ monkeypatch.setattr(hm_info, "fetch_text", self.fetch_text)
+ monkeypatch.setattr(hm_info, "fetch_data", self.fetch_data)
+ monkeypatch.setattr(hm_info, "download", self.download)
+
+ def add(self, path, data):
+ if isinstance(data, str):
+ data = data.encode()
+
+ self.files[path] = data
+
+ def _path(self, url):
+ return url.split("/raw/main/", 1)[1]
+
+ def fetch_text(self, url):
+ self.fetched.append(url)
+ if self.tree_text is not None:
+ return self.tree_text
+
+ return json.dumps({'tree': [{'type': 'tree', 'path': 'images', 'sha': 'x', 'size': 0}, {'type': 'blob', 'path': '.github/x.yml', 'sha': 'x', 'size': 1}] + [
+ {'type': 'blob', 'path': path, 'sha': calculate_git_blob_sha_data(data), 'size': len(data)}
+ for path, data in self.files.items()]})
+
+ def _data(self, path):
+ data = self.files.get(path)
+ if data is not None and path in self.corrupt:
+ data = data + b"corrupted"
+
+ return data
+
+ def fetch_data(self, url):
+ self.fetched.append(url)
+ return self._data(self._path(url))
+
+ def download(self, file_name, url, callback=None, no_check=False):
+ self.downloaded.append(url)
+ data = self._data(self._path(url))
+ if data is None:
+ return None
+
+ file_name.write_bytes(data)
+ return file_name
+
+
+@pytest.fixture
+def info_repo(monkeypatch, hm_dirs):
+ return FakeInfoRepo(monkeypatch, hm_dirs['temp_dir'])
+
+
+@pytest.fixture
+def info_hm(make_hm):
+ """An offline HarbourMaster, switched online for load_info() calls."""
+ hm = make_hm()
+ hm.config['offline'] = False
+ hm.config['no-check'] = False
+ hm.cfg_data.pop('last-info-check', None)
+ return hm
+
+
+def zip_bytes(files):
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, 'w') as zf:
+ for name, data in files.items():
+ zf.writestr(name, data)
+
+ return buffer.getvalue()
+
+
+################################################################################
+## The local files
+def test_base_files_are_created(hm):
+ cfg_dir = hm.cfg_dir
+
+ assert json.loads((cfg_dir / "ports_info.json").read_text()) == {"items": {}, "md5": {}, "ports": {}, "portsmd_fix": {}}
+ assert json.loads((cfg_dir / "porters.json").read_text()) == {}
+ assert json.loads((cfg_dir / "port_stats.json").read_text()) == {"ports": {}, "total_downloads": 0}
+ assert json.loads((cfg_dir / "featured_ports.json").read_text()) == []
+ assert not (cfg_dir / "featured_images.zip").exists()
+ assert (cfg_dir / "featured_ports").is_dir()
+
+
+def test_existing_files_are_kept(hm):
+ (hm.cfg_dir / "porters.json").write_text('{"klops": {}}')
+ hm.load_info()
+
+ assert json.loads((hm.cfg_dir / "porters.json").read_text()) == {"klops": {}}
+
+
+def test_old_config_keys_are_dropped(hm):
+ hm.cfg_data.update({"featured_ports_checked": 1, "porters_checked": 2, "ports_info_checked": 3, "keep": 4})
+ hm.load_info()
+
+ assert hm.cfg_data["keep"] == 4
+ for key in ("featured_ports_checked", "porters_checked", "ports_info_checked"):
+ assert key not in hm.cfg_data
+
+
+@pytest.mark.parametrize("contents", ["", "not json", "null"])
+def test_bad_ports_info_falls_back_to_empty(hm, contents):
+ (hm.cfg_dir / "ports_info.json").write_text(contents)
+ hm._PORTS_INFO = None
+
+ assert hm.ports_info() == {"items": {}, "md5": {}, "ports": {}, "portsmd_fix": {}}
+
+
+@pytest.mark.parametrize("contents", ["", "{]", "null"])
+def test_bad_porters_falls_back_to_empty(hm, contents):
+ (hm.cfg_dir / "porters.json").write_text(contents)
+ hm._PORTERS = None
+
+ assert hm.porters() == {}
+ assert hm.porters_list() == []
+
+
+def test_porters_list(hm):
+ (hm.cfg_dir / "porters.json").write_text('{"klops": {}, "Cebion": {}}')
+ hm._PORTERS = None
+
+ assert hm.porters_list() == ["klops", "Cebion"]
+
+
+@pytest.mark.parametrize("contents, expected", [
+ ('{"ports": {"sprout.zip": 12}}', 12),
+ ('{"ports": {}}', 0),
+ ('{"total_downloads": 5}', 0),
+ ('[1, 2]', 0),
+ ('garbage', 0),
+ (None, 0),
+ ])
+def test_port_downloads(hm, contents, expected):
+ stats = hm.cfg_dir / "port_stats.json"
+ if contents is None:
+ stats.unlink()
+ else:
+ stats.write_text(contents)
+
+ hm._PORTS_DOWNLOAD = None
+ assert hm.port_downloads("sprout.zip") == expected
+ assert hm.port_downloads("other.zip") == 0
+
+
+################################################################################
+## Fetching
+def test_offline_does_not_fetch(hm, info_repo):
+ hm.load_info()
+ assert info_repo.fetched == []
+
+
+def test_fetch_new_files(info_hm, info_repo):
+ info_repo.add("ports_info.json", '{"items": {"a.sh": ["a.zip"]}, "md5": {}, "ports": {}, "portsmd_fix": {}}')
+ info_repo.add("porters.json", '{"klops": {}}')
+ info_repo.add("README.md", "not wanted")
+
+ info_hm.load_info()
+
+ cfg_dir = info_hm.cfg_dir
+ assert json.loads((cfg_dir / "porters.json").read_text()) == {"klops": {}}
+ assert "a.sh" in json.loads((cfg_dir / "ports_info.json").read_text())['items']
+ ## Only the files PortMaster uses.
+ assert not (cfg_dir / "README.md").exists()
+ assert info_hm.cfg_data['last-info-check']
+
+
+def test_unchanged_files_are_not_fetched(info_hm, info_repo):
+ info_repo.add("porters.json", (info_hm.cfg_dir / "porters.json").read_bytes())
+ info_hm.load_info()
+
+ assert info_repo.fetched == [info_hm.PORT_INFO_JSON]
+
+
+def test_checked_recently_does_not_fetch(info_hm, info_repo):
+ import datetime
+ info_hm.cfg_data['last-info-check'] = datetime.datetime.now().isoformat()
+ info_hm.load_info()
+
+ assert info_repo.fetched == []
+
+
+def test_checked_long_ago_fetches(info_hm, info_repo):
+ info_hm.cfg_data['last-info-check'] = "2020-01-01T00:00:00"
+ info_hm.load_info()
+
+ assert info_repo.fetched == [info_hm.PORT_INFO_JSON]
+
+
+def test_tree_unavailable_goes_offline(info_hm, monkeypatch):
+ ## A failed tree fetch (no network, or the API's rate limit) switches the whole
+ ## session to offline: the port sources don't update either.
+ monkeypatch.setattr(hm_info, "fetch_text", lambda url: None)
+ info_hm.load_info()
+
+ assert info_hm.config['offline'] is True
+ assert 'last-info-check' not in info_hm.cfg_data
+
+
+@pytest.mark.parametrize("tree_text", ["not json", "[]", '{"no": "tree"}', "null"])
+def test_bad_tree_is_ignored(info_hm, info_repo, tree_text, log_errors):
+ info_repo.tree_text = tree_text
+ info_hm.load_info()
+
+ assert info_hm.config['offline'] is False
+ assert 'last-info-check' not in info_hm.cfg_data
+ assert any("Unable to fetch a sensible version" in message for message in log_errors)
+
+
+def test_failed_small_download_keeps_the_old_file(info_hm, info_repo, callback):
+ info_repo.add("porters.json", '{"new": {}}')
+ info_repo.corrupt.add("porters.json")
+ info_hm.load_info()
+
+ assert json.loads((info_hm.cfg_dir / "porters.json").read_text()) == {}
+ assert "Download validation failed." in callback.messages
+
+
+def test_missing_small_download(info_hm, info_repo, callback, monkeypatch):
+ info_repo.add("porters.json", '{"new": {}}')
+ monkeypatch.setattr(hm_info, "fetch_data", lambda url: None)
+ info_hm.load_info()
+
+ assert json.loads((info_hm.cfg_dir / "porters.json").read_text()) == {}
+ assert "Unable to download file." in callback.messages
+
+
+def big(text):
+ ## Over 1MB: downloaded with a progress bar rather than fetched.
+ return text + " " * (1024 * 1024 + 1)
+
+
+def test_big_download(info_hm, info_repo):
+ info_repo.add("ports_info.json", big('{"items": {}, "md5": {}, "ports": {"big.zip": {}}, "portsmd_fix": {}}'))
+ info_hm.load_info()
+
+ local = info_hm.cfg_dir / "ports_info.json"
+ assert "big.zip" in json.loads(local.read_text())['ports']
+ assert len(info_repo.downloaded) == 1
+ ## The temp copy is cleaned up.
+ assert not (info_hm.temp_dir / "ports_info.json").exists()
+
+
+def test_big_download_failure(info_hm, info_repo, monkeypatch):
+ info_repo.add("ports_info.json", big("{}"))
+ monkeypatch.setattr(hm_info, "download", lambda *args, **kwargs: None)
+
+ before = (info_hm.cfg_dir / "ports_info.json").read_text()
+ info_hm.load_info()
+ assert (info_hm.cfg_dir / "ports_info.json").read_text() == before
+
+
+def test_big_download_validation_failure_keeps_the_old_file(info_hm, info_repo, callback):
+ ## It deleted the bad download and then copied it over the old file: FileNotFoundError.
+ info_repo.add("ports_info.json", big("{}"))
+ info_repo.corrupt.add("ports_info.json")
+
+ before = (info_hm.cfg_dir / "ports_info.json").read_text()
+ info_hm.load_info()
+
+ assert (info_hm.cfg_dir / "ports_info.json").read_text() == before
+ assert "Download validation failed." in callback.messages
+
+
+################################################################################
+## Featured ports
+def test_featured_images_zip_is_extracted(info_hm, info_repo):
+ info_repo.add("featured_images.zip", zip_bytes({
+ "images/one.png": b"png1",
+ "images/sub/two.jpg": b"jpg2",
+ "images/": b"",
+ "images/readme.txt": b"skip",
+ "../escape.png": b"flattened",
+ }))
+ info_hm.load_info()
+
+ featured = info_hm.cfg_dir / "featured_ports"
+ assert sorted(path.name for path in featured.iterdir()) == ["escape.png", "one.png", "two.jpg"]
+ assert (featured / "two.jpg").read_bytes() == b"jpg2"
+ assert not (info_hm.cfg_dir / "escape.png").exists()
+
+
+def test_featured_images_fetched_one_by_one(info_hm, info_repo):
+ info_repo.add("images/three.png", b"png3")
+ info_repo.add("images/notes.txt", b"skip")
+ info_hm.load_info()
+
+ featured = info_hm.cfg_dir / "featured_ports"
+ assert (featured / "three.png").read_bytes() == b"png3"
+ assert not (featured / "notes.txt").exists()
+
+ ## Up to date now: not fetched again.
+ info_repo.fetched.clear()
+ info_hm.cfg_data.pop('last-info-check')
+ info_hm.load_info()
+ assert info_repo.fetched == [info_hm.PORT_INFO_JSON]
+
+
+def test_images_in_the_zip_are_not_fetched_again(info_hm, info_repo):
+ info_repo.add("featured_images.zip", zip_bytes({"images/one.png": b"png1"}))
+ info_repo.add("images/one.png", b"png1")
+ info_hm.load_info()
+
+ assert not any(url.endswith("images/one.png") for url in info_repo.fetched)
+
+
+################################################################################
+## runtimes.json
+def test_runtimes_file_is_loaded(make_hm, hm_dirs):
+ hm = make_hm()
+ hm.runtimes_file.write_text(json.dumps({"mono.squashfs": {"name": "Mono", "status": "Unknown", "remote": {}}}))
+
+ hm.load_info()
+ assert hm.runtimes_info["mono.squashfs"]["name"] == "Mono"
+
+
+def test_bad_runtimes_file(make_hm):
+ hm = make_hm()
+ hm.runtimes_file.write_text("garbage")
+
+ hm.load_info()
+ assert hm.runtimes_info == {}
+
+
+def test_git_blob_sha():
+ ## The same as `git hash-object`.
+ assert calculate_git_blob_sha_data(b"") == "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391"
+ assert calculate_git_blob_sha_data(b"hello\n") == "ce013625030ba8dba906f756967f9e9ca394464a"
+
+
+def test_git_blob_sha_file(tmp_path):
+ path = tmp_path / "x"
+ path.write_bytes(b"hello\n")
+ assert calculate_git_blob_sha_file(path) == "ce013625030ba8dba906f756967f9e9ca394464a"
+ assert calculate_git_blob_sha_file(tmp_path / "missing") is None
diff --git a/tests/test_hm_ports.py b/tests/test_hm_ports.py
new file mode 100644
index 00000000..df161b8a
--- /dev/null
+++ b/tests/test_hm_ports.py
@@ -0,0 +1,774 @@
+# SPDX-License-Identifier: MIT
+#
+# HarbourMaster's ports mixin (hm_ports.py): finding installed ports on disk (renamed
+# scripts, old port.json names, broken and unknown ports), filtering, sorting and info.
+
+import json
+
+import pytest
+
+from harbourmaster import util
+
+from conftest import basic_port_files, write_port_zip
+
+
+PORTS_INFO = {
+ "items": {
+ "Test Port.sh": "testport.zip",
+ "testport/": "testport.zip",
+ "Abes Adventure.sh": "abes.zip",
+ "abes/": "abes.zip",
+ "Shared.sh": ["one.zip", "two.zip"],
+ "SPROUT.sh": "sprout.zip",
+ "sprout": "sprout.zip",
+ "sprout/": "sprout.zip",
+ },
+ "md5": {},
+ "ports": {
+ "testport.zip": {"date": ["2021-01-01", "2022-01-01"], "file": "testport/port.json", "items": ["Test Port.sh", "testport/"], "name": "testport.zip"},
+ "abes.zip": {"date": ["2020-01-01", "2020-01-01"], "file": "abes/abes.port.json", "items": ["Abes Adventure.sh", "abes/"], "name": "abes.zip"},
+ "sprout.zip": {"date": ["2025-07-16", "2025-07-16"], "file": "sprout/port.json", "items": ["SPROUT.sh", "sprout/"], "name": "sprout.zip"},
+ },
+ "portsmd_fix": {"cebion": ["Cebion"]},
+ }
+
+
+def set_ports_info(hm, data=None, **changes):
+ data = json.loads(json.dumps(data or PORTS_INFO))
+ data.update(changes)
+ (hm.cfg_dir / "ports_info.json").write_text(json.dumps(data))
+ hm._PORTS_INFO = None
+ return data
+
+
+@pytest.fixture
+def ports_dir(hm_dirs):
+ return hm_dirs['ports_dir']
+
+
+def install_files(ports_dir, files):
+ """Lays out port files as an install would, without harbourmaster."""
+ for name, data in files.items():
+ path = ports_dir / name
+ if name.endswith('/'):
+ path.mkdir(parents=True, exist_ok=True)
+ continue
+
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if isinstance(data, dict):
+ data = json.dumps(data)
+
+ path.write_text(data)
+
+
+def port_json(ports_dir, name="testport", file_name="port.json"):
+ return json.loads((ports_dir / name / file_name).read_text())
+
+
+@pytest.fixture
+def installed(hm, hm_dirs):
+ """testport.zip installed by harbourmaster."""
+ zip_file = write_port_zip(hm_dirs['temp_dir'] / "testport.zip", basic_port_files())
+ assert hm.install_port(str(zip_file)) == 0
+ hm.load_ports()
+ return hm
+
+
+################################################################################
+## load_ports: port.json files
+def test_installed_port_found(installed):
+ assert list(installed.installed_ports) == ["testport.zip"]
+ assert installed.broken_ports == {}
+ assert installed.unknown_ports == []
+
+
+def test_old_port_json_name_is_renamed(hm, ports_dir):
+ files = basic_port_files()
+ files["testport/testport.port.json"] = files.pop("testport/port.json")
+ install_files(ports_dir, files)
+
+ hm.load_ports()
+
+ assert (ports_dir / "testport" / "port.json").is_file()
+ assert not (ports_dir / "testport" / "testport.port.json").exists()
+ assert list(hm.installed_ports) == ["testport.zip"]
+
+
+def test_old_port_json_next_to_a_new_one_is_removed(hm, ports_dir):
+ files = basic_port_files()
+ files["testport/testport.port.json"] = '{"old": true}'
+ install_files(ports_dir, files)
+
+ hm.load_ports()
+
+ assert not (ports_dir / "testport" / "testport.port.json").exists()
+ assert list(hm.installed_ports) == ["testport.zip"]
+
+
+def test_two_old_port_jsons_are_left_alone(hm, ports_dir):
+ files = basic_port_files()
+ pj = files.pop("testport/port.json")
+ files["testport/a.port.json"] = pj
+ files["testport/b.port.json"] = pj
+ install_files(ports_dir, files)
+
+ hm.load_ports()
+
+ assert (ports_dir / "testport" / "a.port.json").is_file()
+ assert (ports_dir / "testport" / "b.port.json").is_file()
+
+
+def test_three_port_jsons_with_a_new_one_are_left_alone(hm, ports_dir):
+ files = basic_port_files()
+ files["testport/a.port.json"] = files["testport/port.json"]
+ files["testport/b.port.json"] = files["testport/port.json"]
+ install_files(ports_dir, files)
+
+ hm.load_ports()
+
+ assert (ports_dir / "testport" / "a.port.json").is_file()
+ assert list(hm.installed_ports) == ["testport.zip"]
+
+
+def test_alephone_port_jsons_are_not_renamed(hm, ports_dir):
+ ## alephone holds several ports' files in one folder.
+ install_files(ports_dir, {"alephone/marathon.port.json": {"name": "marathon.zip", "items": ["Marathon.sh"], "attr": {}}})
+
+ hm.load_ports()
+
+ assert (ports_dir / "alephone" / "marathon.port.json").is_file()
+
+
+@pytest.mark.parametrize("contents", ["", "{", "[]", "null", '"text"'])
+def test_unreadable_port_json_is_skipped(hm, ports_dir, contents):
+ files = basic_port_files()
+ files["testport/port.json"] = contents
+ install_files(ports_dir, files)
+
+ hm.load_ports()
+
+ assert hm.installed_ports == {}
+ ## Its script is then a port nobody owns.
+ assert hm.unknown_ports == ["Test Port.sh"]
+
+
+def test_port_json_name_is_lowercased(hm, ports_dir):
+ install_files(ports_dir, basic_port_files())
+ pj = port_json(ports_dir)
+ pj['name'] = "TestPort.ZIP"
+ install_files(ports_dir, {"testport/port.json": pj})
+
+ hm.load_ports()
+
+ assert list(hm.installed_ports) == ["testport.zip"]
+ assert port_json(ports_dir)['name'] == "testport.zip"
+
+
+def test_port_json_without_a_title_uses_the_script(hm, ports_dir):
+ install_files(ports_dir, basic_port_files(title=""))
+
+ hm.load_ports()
+
+ assert hm.installed_ports["testport.zip"]['attr']['title'] == "Test Port"
+
+
+def test_port_json_gets_status_and_files(hm, ports_dir):
+ install_files(ports_dir, basic_port_files())
+ hm.load_ports()
+
+ pj = port_json(ports_dir)
+ assert pj['status']['status'] == "Installed"
+ assert pj['status']['source'] == "Unknown"
+ assert pj['files'] == {"port.json": "testport/port.json", "Test Port.sh": "Test Port.sh", "testport/": "testport/"}
+
+
+def test_port_json_source_is_dropped(hm, ports_dir):
+ files = basic_port_files()
+ files["testport/port.json"]["source"] = {"md5": "x"}
+ install_files(ports_dir, files)
+
+ hm.load_ports()
+ assert 'source' not in port_json(ports_dir)
+
+
+def test_port_json_porter_string_is_a_list(hm, ports_dir):
+ install_files(ports_dir, basic_port_files(porter="Somebody"))
+ hm.load_ports()
+ assert hm.installed_ports["testport.zip"]['attr']['porter'] == ["Somebody"]
+
+
+def test_port_json_optional_items(hm, ports_dir):
+ files = basic_port_files()
+ files["testport/port.json"]["items_opt"] = ["testport-extra/", "missing-extra/"]
+ files["testport-extra/"] = ""
+ install_files(ports_dir, files)
+
+ hm.load_ports()
+
+ files = hm.installed_ports["testport.zip"]['files']
+ assert files["testport-extra/"] == "testport-extra/"
+ assert "missing-extra/" not in files
+
+
+def test_port_json_without_items_is_restored_from_ports_info(hm, ports_dir):
+ set_ports_info(hm)
+ files = basic_port_files()
+ del files["testport/port.json"]["items"]
+ install_files(ports_dir, files)
+
+ hm.load_ports()
+
+ assert hm.installed_ports["testport.zip"]['items'] == ["Test Port.sh", "testport/"]
+
+
+def test_port_json_without_items_unknown_port(hm, ports_dir):
+ files = basic_port_files(name="mystery", script="Mystery.sh")
+ del files["mystery/port.json"]["items"]
+ install_files(ports_dir, files)
+
+ hm.load_ports()
+ assert hm.installed_ports == {}
+
+
+def test_port_json_without_name_or_items(hm, ports_dir):
+ install_files(ports_dir, {"testport/port.json": {"attr": {}}})
+ hm.load_ports()
+ assert hm.installed_ports == {}
+
+
+def test_port_json_without_name_and_unknown_items(hm, ports_dir):
+ set_ports_info(hm)
+ install_files(ports_dir, {"thing/port.json": {"items": ["Nobody.sh"], "attr": {}}, "Nobody.sh": "#!/bin/bash\n"})
+ hm.load_ports()
+ assert hm.installed_ports == {}
+
+
+@pytest.mark.parametrize("script, port_name", [
+ ("Test Port.sh", "testport.zip"),
+ ## ports_info.json keeps the script's case.
+ ("Abes Adventure.sh", "abes.zip"),
+ ])
+def test_port_json_without_name_is_found_by_its_items(hm, ports_dir, script, port_name):
+ ## ports_info.json's items are mostly strings: this took the name's first letter.
+ set_ports_info(hm)
+ folder = port_name[:-4]
+ install_files(ports_dir, {
+ f"{folder}/port.json": {"items": [script, f"{folder}/"], "attr": {"title": "x"}},
+ script: "#!/bin/bash\n",
+ })
+
+ hm.load_ports()
+
+ assert list(hm.installed_ports) == [port_name]
+
+
+################################################################################
+## load_ports: scripts and folders
+def test_script_gets_a_signature(hm, ports_dir):
+ install_files(ports_dir, basic_port_files())
+ hm.load_ports()
+
+ assert util.load_pm_signature(ports_dir / "Test Port.sh") == ["testport.zip", "Test Port.sh"]
+
+
+def test_no_touchy_scripts_are_ignored(hm, ports_dir):
+ install_files(ports_dir, {"Mine.sh": "#!/bin/bash\n# PORTMASTER NO TOUCHY\n"})
+ hm.load_ports()
+
+ assert hm.unknown_ports == []
+ assert util.load_pm_signature(ports_dir / "Mine.sh") is None
+
+
+@pytest.mark.parametrize("name", ["PortMaster.sh", "gamelist.xml", "images", "videos", "manuals", "ThemeMaster.sh", "harbourmaster"])
+def test_frontend_files_are_ignored(hm, ports_dir, name):
+ if "." in name:
+ install_files(ports_dir, {name: "#!/bin/bash\n"})
+ else:
+ install_files(ports_dir, {f"{name}/": ""})
+
+ hm.load_ports()
+ assert hm.unknown_ports == []
+
+
+def test_non_scripts_are_ignored(hm, ports_dir):
+ install_files(ports_dir, {"readme.txt": "hello", "Game.SH": "#!/bin/bash\n"})
+ hm.load_ports()
+
+ ## Upper case .SH still counts as a script.
+ assert hm.unknown_ports == ["Game.SH"]
+
+
+def test_unknown_script(hm, ports_dir):
+ install_files(ports_dir, {"Mystery.sh": "#!/bin/bash\n", "mystery/": ""})
+ hm.load_ports()
+
+ assert hm.unknown_ports == ["Mystery.sh"]
+ assert hm.installed_ports == {}
+
+
+def test_renamed_script_is_tracked(installed, ports_dir):
+ (ports_dir / "Test Port.sh").rename(ports_dir / "My Favourite.sh")
+ installed.load_ports()
+
+ assert list(installed.installed_ports) == ["testport.zip"]
+ assert installed.installed_ports["testport.zip"]['files']["Test Port.sh"] == "My Favourite.sh"
+ assert installed.unknown_ports == []
+
+
+def test_renamed_and_copied_script(installed, ports_dir):
+ ## Both names exist: the port owns both.
+ (ports_dir / "Copy.sh").write_bytes((ports_dir / "Test Port.sh").read_bytes())
+ installed.load_ports()
+
+ files = installed.installed_ports["testport.zip"]['files']["Test Port.sh"]
+ assert sorted(files) == ["Copy.sh", "Test Port.sh"]
+
+
+def test_renamed_script_found_by_md5(hm, ports_dir):
+ ## An unsigned, renamed script: its md5 names the original.
+ install_files(ports_dir, basic_port_files())
+ script = ports_dir / "Test Port.sh"
+ md5 = util.hash_file(script)
+ set_ports_info(hm, md5={md5: "Test Port.sh"})
+ script.rename(ports_dir / "Renamed.sh")
+
+ hm.load_ports()
+
+ assert hm.unknown_ports == []
+ assert util.load_pm_signature(ports_dir / "Renamed.sh") == ["testport.zip", "Test Port.sh"]
+ assert hm.installed_ports["testport.zip"]['files']["Test Port.sh"] == "Renamed.sh"
+
+
+def test_missing_script_means_broken(installed, ports_dir):
+ (ports_dir / "Test Port.sh").unlink()
+ installed.load_ports()
+
+ assert installed.installed_ports == {}
+ assert list(installed.broken_ports) == ["testport.zip"]
+ assert port_json(ports_dir)['status']['status'] == "Broken"
+
+ ## And back.
+ install_files(ports_dir, {"Test Port.sh": "#!/bin/bash\n"})
+ installed.load_ports()
+ assert list(installed.installed_ports) == ["testport.zip"]
+ assert port_json(ports_dir)['status']['status'] == "Installed"
+
+
+def test_missing_folder_means_broken(installed, ports_dir):
+ import shutil
+ shutil.move(str(ports_dir / "testport" / "port.json"), str(ports_dir / "port.json.keep"))
+ shutil.rmtree(str(ports_dir / "testport"))
+ (ports_dir / "testport").mkdir()
+ shutil.move(str(ports_dir / "port.json.keep"), str(ports_dir / "testport" / "port.json"))
+ (ports_dir / "testport").rename(ports_dir / "renamed")
+
+ installed.load_ports()
+
+ ## The port.json moved with its folder, the folder item is missing.
+ assert list(installed.broken_ports) == ["testport.zip"]
+
+
+def test_shared_script_belongs_to_nobody_new(hm, ports_dir):
+ ## Two ports in ports_info claim it: no port.json is made up.
+ set_ports_info(hm)
+ install_files(ports_dir, {"Shared.sh": "#!/bin/bash\n"})
+
+ hm.load_ports()
+
+ assert hm.installed_ports == {}
+ assert hm.unknown_ports == []
+
+
+################################################################################
+## load_ports: ports without a port.json (installed by old PortMasters)
+def test_port_json_is_made_for_a_known_script(online_hm, ports_dir):
+ set_ports_info(online_hm)
+ install_files(ports_dir, {"SPROUT.sh": "#!/bin/bash\n", "sprout/": ""})
+
+ online_hm.load_ports()
+
+ assert list(online_hm.installed_ports) == ["sprout.zip"]
+ pj = port_json(ports_dir, "sprout")
+ ## Filled in from the source.
+ assert pj['attr']['title'] == "-SPROUT-"
+ assert pj['status'] == {"source": "Unknown", "md5": None, "status": "Installed"}
+ assert pj['files']["port.json"] == "sprout/port.json"
+
+
+def test_port_json_is_made_without_a_source(hm, ports_dir):
+ set_ports_info(hm)
+ install_files(ports_dir, {"Test Port.sh": "#!/bin/bash\n", "testport/": ""})
+
+ hm.load_ports()
+
+ pj = port_json(ports_dir)
+ assert pj['name'] == "testport.zip"
+ ## No title anywhere: the script's name.
+ assert pj['attr']['title'] == "Test Port"
+ assert pj['attr']['porter'] == []
+
+
+def test_port_json_made_for_renamed_script(hm, ports_dir):
+ set_ports_info(hm)
+ install_files(ports_dir, {"Renamed.sh": "#!/bin/bash\n", "testport/": ""})
+ util.add_pm_signature(ports_dir / "Renamed.sh", ["testport.zip", "Test Port.sh"])
+
+ hm.load_ports()
+
+ assert list(hm.installed_ports) == ["testport.zip"]
+ assert hm.installed_ports["testport.zip"]['files']["Test Port.sh"] == "Renamed.sh"
+ ## It was listed as an unknown port too.
+ assert hm.unknown_ports == []
+
+
+def test_port_json_cant_be_made_without_its_folder(hm, ports_dir, log_errors):
+ ## ports_info says the port.json lives in a folder that isn't there.
+ set_ports_info(hm)
+ install_files(ports_dir, {"Test Port.sh": "#!/bin/bash\n"})
+
+ hm.load_ports()
+
+ assert list(hm.broken_ports) == ["testport.zip"]
+ assert not (ports_dir / "testport").exists()
+
+
+################################################################################
+## Separate scripts folder (eg: ROMS/Ports and ports/)
+def test_scripts_dir_separate(make_hm, hm_dirs, tmp_path):
+ scripts_dir = tmp_path / "scripts"
+ scripts_dir.mkdir()
+ hm_dirs['scripts_dir'] = scripts_dir
+ hm = make_hm()
+
+ zip_file = write_port_zip(hm_dirs['temp_dir'] / "testport.zip", basic_port_files())
+ assert hm.install_port(str(zip_file)) == 0
+ hm.load_ports()
+
+ assert (scripts_dir / "Test Port.sh").is_file()
+ assert list(hm.installed_ports) == ["testport.zip"]
+ assert hm._ports_dir_is_file("Test Port.sh")
+ assert hm._ports_dir_is_dir("testport")
+ assert not hm._ports_dir_is_file("testport")
+ assert not hm._ports_dir_is_dir("nothing")
+ assert hm._ports_dir_relative_to(scripts_dir / "a.sh").as_posix() == "a.sh"
+ assert hm._ports_dir_relative_to(hm_dirs['ports_dir'] / "b" / "c").as_posix() == "b/c"
+ assert hm._ports_dir_file("x.sh", is_script=True) == scripts_dir / "x.sh"
+ assert hm._ports_dir_file("x/") == hm_dirs['ports_dir'] / "x"
+
+
+def test_get_pm_signature_non_script(hm, ports_dir):
+ assert hm._get_pm_signature(ports_dir / "thing.txt") is None
+
+
+################################################################################
+## Attributes and filters
+def make_info(name="x.zip", **attr):
+ info = {"name": name, "attr": {"genres": [], "porter": [], **attr}}
+ return info
+
+
+@pytest.mark.parametrize("runtime, expected", [
+ ("frt_3.5.2.squashfs", ["godot"]),
+ (["mono-6.12.0.122-aarch64.squashfs"], ["mono"]),
+ (["jdk11.squashfs", "weston_pkg_0.2.squashfs", "mesa_pkg_0.1.squashfs"], ["jre", "weston", "mesa"]),
+ (["solarus-1.6.5.squashfs", "rlvm.squashfs"], ["rlvm", "solarus"]),
+ (["dotnet-8.0.12.squashfs"], []),
+ ])
+def test_port_info_attrs_runtimes(hm, runtime, expected):
+ attrs = hm.port_info_attrs(make_info(runtime=runtime))
+ assert [attr for attr in attrs if attr not in ("!rtr",)] == expected
+
+
+def test_port_info_attrs(hm):
+ attrs = hm.port_info_attrs(make_info(genres=["Puzzle", "RPG"], porter=["Klops"], rtr=True, exp=True))
+ assert attrs == ["puzzle", "rpg", "klops", "rtr", "exp"]
+
+
+def test_port_info_attrs_minimal(hm):
+ assert hm.port_info_attrs({"name": "x.zip"}) == ["!rtr"]
+
+
+def test_port_info_attrs_installed_and_broken(hm):
+ hm.installed_ports = {"a.zip": {}}
+ hm.broken_ports = {"b.zip": {}}
+
+ assert "installed" in hm.port_info_attrs({"name": "A.zip"})
+ assert {"installed", "broken"} <= set(hm.port_info_attrs({"name": "b.zip"}))
+
+
+@pytest.mark.parametrize("source_md5, status_md5, update", [
+ ("aaa", "bbb", True),
+ ("aaa", "aaa", False),
+ ("aaa", None, False),
+ (None, "bbb", True),
+ ])
+def test_port_info_attrs_update_available(hm, source_md5, status_md5, update):
+ info = make_info(source={})
+ info['source'] = {"md5": source_md5}
+ info['status'] = {"md5": status_md5}
+
+ assert ("update available" in hm.port_info_attrs(info)) is update
+
+
+def test_match_filters(hm):
+ info = make_info(genres=["puzzle"], rtr=True)
+
+ assert hm.match_filters([], info)
+ assert hm.match_filters(["PUZZLE", "rtr"], info)
+ assert not hm.match_filters(["puzzle", "rpg"], info)
+
+
+def test_match_filters_experimental(hm):
+ info = make_info(exp=True)
+
+ hm.cfg_data['show_experimental'] = False
+ assert not hm.match_filters([], info)
+
+ hm.cfg_data['show_experimental'] = True
+ assert hm.match_filters(["exp"], info)
+
+
+################################################################################
+## Requirements
+@pytest.mark.parametrize("attr, expected", [
+ ({}, True),
+ ({"reqs": None}, True),
+ ({"reqs": ["opengl"]}, True),
+ ({"reqs": ["!opengl"]}, False),
+ ({"reqs": ["vulkan"]}, False),
+ ({"reqs": ["vulkan|opengl"]}, True),
+ ({"arch": ["x86_64"]}, False),
+ ({"arch": ["x86_64", "aarch64"]}, True),
+ ({"arch": "aarch64"}, True),
+ ({"min_glibc": "2.30"}, True),
+ ({"min_glibc": " 2.29 "}, True),
+ ({"min_glibc": "2.31"}, False),
+ ({"min_glibc": None}, True),
+ ({"min_glibc": 3}, True),
+ ])
+def test_match_requirements(hm, attr, expected):
+ assert hm.match_requirements({"name": "x.zip", "attr": attr}) is expected
+
+
+def test_match_requirements_runtime_arch(hm):
+ hm.runtimes_info = {
+ "arm.squashfs": {"remote": {"aarch64": {}}},
+ "pc.squashfs": {"remote": {"x86_64": {}}},
+ }
+
+ assert hm.match_requirements({"name": "a", "attr": {"runtime": ["arm"]}})
+ assert not hm.match_requirements({"name": "b", "attr": {"runtime": ["pc.squashfs"]}})
+ ## The runtime's arch wins over the port's own.
+ assert hm.match_requirements({"name": "c", "attr": {"runtime": ["arm.squashfs"], "arch": ["x86_64"]}})
+ ## An unknown runtime adds an empty requirement, which passes.
+ assert hm.match_requirements({"name": "d", "attr": {"runtime": ["unknown"]}})
+
+
+def test_match_requirements_show_all(hm):
+ hm.cfg_data['show_all'] = True
+ assert hm.match_requirements({"name": "x", "attr": {"reqs": ["vulkan"], "min_glibc": ""}})
+ ## glibc is checked first.
+ assert not hm.match_requirements({"name": "x", "attr": {"min_glibc": "9.0"}})
+
+
+def test_match_requirements_debug_prints_once(hm, monkeypatch, capsys):
+ from harbourmaster import config as hm_config
+ monkeypatch.setattr(hm_config, "HM_DEBUG", True)
+ hm.runtimes_info = {"arm.squashfs": {"remote": {"aarch64": {}}}}
+
+ hm.match_requirements({"name": "x", "attr": {"runtime": ["arm"]}})
+ hm.match_requirements({"name": "x", "attr": {"runtime": ["arm"]}})
+ hm.match_requirements({"name": "y", "attr": {}})
+
+ out = capsys.readouterr().out
+ assert out.count("x: ") == 1
+ assert "y: " not in out
+
+
+################################################################################
+## Listing and sorting
+def titles(ports):
+ return [info['attr']['title'] for info in ports.values()]
+
+
+def test_list_ports(online_hm):
+ ports = online_hm.list_ports()
+ assert titles(ports) == ["-SPROUT-", "100 Lil Jumps", "2048 Plus"]
+
+
+def test_list_ports_unknown_sort_is_alphabetical(online_hm):
+ assert list(online_hm.list_ports(sort_by="nope")) == list(online_hm.list_ports())
+
+
+def test_list_ports_reverse(online_hm):
+ assert titles(online_hm.list_ports(reverse=True)) == ["2048 Plus", "100 Lil Jumps", "-SPROUT-"]
+
+
+@pytest.mark.parametrize("lister", ["list_ports", "list_ports_new"])
+def test_list_ports_recently_added_is_newest_first(online_hm, ports_json_data, lister):
+ ports = getattr(online_hm, lister)(sort_by="recently_added")
+ dates = [ports_json_data['ports'][name]['source']['date_added'] for name in ports]
+ assert dates == sorted(dates, reverse=True)
+
+
+@pytest.mark.parametrize("lister", ["list_ports", "list_ports_new"])
+def test_list_ports_recently_updated(online_hm, ports_json_data, lister):
+ ports = getattr(online_hm, lister)(sort_by="recently_updated")
+ dates = [ports_json_data['ports'][name]['source']['date_updated'] for name in ports]
+ assert dates == sorted(dates, reverse=True)
+
+
+def test_list_ports_new_total_downloads(online_hm):
+ (online_hm.cfg_dir / "port_stats.json").write_text(json.dumps({"ports": {"2048plus.zip": 50, "sprout.zip": 7}}))
+ online_hm._PORTS_DOWNLOAD = None
+ online_hm._PORT_INFO_CACHE.clear()
+
+ ports = online_hm.list_ports_new(sort_by="total_downloads")
+ assert list(ports)[:2] == ["2048plus.zip", "sprout.zip"]
+
+
+def test_list_ports_filters(online_hm):
+ assert list(online_hm.list_ports(["puzzle"])) == ["2048plus.zip"]
+ assert list(online_hm.list_ports(["puzzle", "platformer"])) == []
+
+
+def test_list_ports_experimental(online_hm):
+ assert "starfighter.zip" not in online_hm.list_ports()
+
+ online_hm.cfg_data['show_experimental'] = True
+ online_hm._port_attrs_updated = True
+ ## Its "!lowres" requirement passes at 640x480.
+ assert "starfighter.zip" in online_hm.list_ports()
+ assert "starfighter.zip" in online_hm.list_ports_new()
+
+
+def test_list_ports_new_matches_list_ports(online_hm):
+ for filters in ([], ["puzzle"], ["platformer"], ["rtr"], ["!rtr"]):
+ assert sorted(online_hm.list_ports_new(filters)) == sorted(online_hm.list_ports(filters)), filters
+
+
+def test_list_ports_installed_filters(online_hm, hm_dirs):
+ assert online_hm.install_port("2048plus.zip") == 0
+ online_hm.load_ports()
+ online_hm._port_attrs_updated = True
+
+ for lister in (online_hm.list_ports, online_hm.list_ports_new):
+ assert list(lister(["installed"])) == ["2048plus.zip"]
+ assert sorted(lister(["not installed"])) == ["100liljumps.zip", "sprout.zip"]
+
+ ## Both at once: nothing.
+ assert online_hm.list_ports_new(["installed", "not installed"]) == {}
+
+
+def test_list_ports_installed_includes_broken(online_hm, hm_dirs):
+ assert online_hm.install_port("2048plus.zip") == 0
+ (hm_dirs['ports_dir'] / "2048 Plus.sh").unlink()
+ online_hm.load_ports()
+ online_hm._port_attrs_updated = True
+
+ assert list(online_hm.list_ports(["installed"])) == ["2048plus.zip"]
+ assert list(online_hm.list_ports_new(["installed"])) == ["2048plus.zip"]
+ assert list(online_hm.list_ports_new(["broken"])) == ["2048plus.zip"]
+ assert list(online_hm.list_ports_new(["installed", "broken"])) == ["2048plus.zip"]
+
+
+def test_list_ports_new_incompatible_ports_are_skipped(online_hm):
+ online_hm.device['capabilities'] = ["x86_64"]
+ online_hm._port_attrs_updated = True
+
+ assert sorted(online_hm.list_ports_new()) == ["2048plus.zip"]
+
+
+def test_list_utils(online_hm):
+ assert online_hm.list_utils() == ['gameinfo.zip', 'ags_3.6.squashfs', 'dotnet-8.0.12.squashfs', 'gmtoolkit.squashfs']
+
+
+################################################################################
+## Port info, sizes, images
+def test_port_info_unknown(online_hm):
+ assert online_hm.port_info("missing.zip") is None
+ ## Cached, None included.
+ assert ("missing.zip", False) in online_hm._PORT_INFO_CACHE
+
+
+def test_port_info_is_cached(online_hm):
+ first = online_hm.port_info("sprout.zip")
+ assert online_hm.port_info("SPROUT.zip") is first
+
+
+def test_port_info_downloads(online_hm):
+ (online_hm.cfg_dir / "port_stats.json").write_text(json.dumps({"ports": {"sprout.zip": 99}}))
+ online_hm._PORTS_DOWNLOAD = None
+
+ assert online_hm.port_info("sprout.zip")['source']['downloads'] == 99
+
+
+def test_port_info_installed_merges_the_source(online_hm):
+ assert online_hm.install_port("2048plus.zip") == 0
+ online_hm.load_ports()
+
+ info = online_hm.port_info("2048plus.zip", installed=True)
+ assert info['status']['status'] == "Installed"
+ assert info['source']['md5']
+
+ info = online_hm.port_info("2048plus.zip", installed=False)
+ assert info['status']['status'] == "Installed"
+
+
+def test_port_info_installed_only(installed):
+ info = installed.port_info("testport.zip", installed=True)
+ assert info['attr']['title'] == "Test Port"
+ assert installed.port_info("testport.zip", installed=False) is None
+
+
+def test_port_download_size_and_url(online_hm, ports_json_data):
+ assert online_hm.port_download_size("2048plus.zip") == ports_json_data['ports']['2048plus.zip']['source']['size']
+ assert online_hm.port_download_url("2048plus.zip") == ports_json_data['ports']['2048plus.zip']['source']['url']
+
+ assert online_hm.port_download_size("gmtoolkit.squashfs") > 0
+ assert online_hm.port_download_url("gmtoolkit.squashfs")
+
+ assert online_hm.port_download_size("missing.zip") == 0
+ assert online_hm.port_download_url("missing.zip") is None
+
+
+def test_port_images_from_the_source(online_hm):
+ source = online_hm.sources['pm']
+ (source._images_dir / "sprout.screenshot.png").write_bytes(b"png")
+ source._load_images()
+
+ images = online_hm.port_images("sprout.zip")
+ assert images == {"screenshot": source._images_dir / "sprout.screenshot.png"}
+
+
+def test_port_images_none(online_hm):
+ assert online_hm.port_images("sprout.zip") is None
+ assert online_hm.port_images("missing.zip") is None
+
+
+@pytest.mark.parametrize("image, files, expected", [
+ ({}, ["screenshot.png"], "screenshot.png"),
+ ({}, ["screenshot.jpg"], "screenshot.jpg"),
+ ({"screenshot": "shot.png"}, ["shot.png", "screenshot.png"], "shot.png"),
+ ({"screenshot": "missing.png"}, ["screenshot.png"], "screenshot.png"),
+ ({"screenshot": "/etc/passwd"}, [], None),
+ ({"screenshot": "../Test Port.sh"}, [], None),
+ ({"screenshot": 5}, [], None),
+ ({}, [], None),
+ ])
+def test_installed_port_images(installed, ports_dir, image, files, expected):
+ for name in files:
+ (ports_dir / "testport" / name).write_bytes(b"img")
+
+ installed.installed_ports["testport.zip"]['attr']['image'] = image
+
+ result = installed.port_images("testport.zip")
+ if expected is None:
+ assert result is None
+ else:
+ assert result == {"screenshot": ports_dir / "testport" / expected}
+
+
+def test_installed_port_images_bad_port_json_entry(installed):
+ installed.installed_ports["testport.zip"]['files']['port.json'] = ["a", "b"]
+ assert installed.port_images("testport.zip") is None
diff --git a/tests/test_hm_runtimes.py b/tests/test_hm_runtimes.py
new file mode 100644
index 00000000..f43b8169
--- /dev/null
+++ b/tests/test_hm_runtimes.py
@@ -0,0 +1,336 @@
+# SPDX-License-Identifier: MIT
+#
+# Runtimes (hm_runtimes.py) and featured ports lists (hm_featured.py).
+
+import hashlib
+import json
+
+import pytest
+
+from harbourmaster import hm_runtimes
+from harbourmaster.util import CancelEvent
+
+
+RUNTIME = "gmtoolkit.squashfs"
+DATA = b"runtime data"
+MD5 = hashlib.md5(DATA).hexdigest()
+
+
+@pytest.fixture
+def rt_hm(online_hm, monkeypatch):
+ """online_hm with gmtoolkit's aarch64 build being DATA, served by `downloads`."""
+ online_hm.runtimes_info[RUNTIME]['remote']['aarch64']['md5'] = MD5
+ online_hm.runtime_downloads = []
+ online_hm.runtime_result = DATA
+
+ def download(file_name, file_url, md5_source=None, md5_result=None, callback=None, no_check=False):
+ online_hm.runtime_downloads.append(file_url)
+ result = online_hm.runtime_result
+ if isinstance(result, BaseException):
+ file_name.write_bytes(b"partial")
+ raise result
+
+ if result is None:
+ return None
+
+ file_name.write_bytes(result)
+ return file_name
+
+ monkeypatch.setattr(hm_runtimes, "download", download)
+ return online_hm
+
+
+def local(hm, runtime=RUNTIME):
+ return hm.runtimes_info[runtime].get('local', {})
+
+
+################################################################################
+## list_runtimes
+def test_list_runtimes_not_installed(rt_hm):
+ result = dict(rt_hm.list_runtimes())
+
+ assert result[RUNTIME]['local'] == {'status': 'Not Installed', 'md5': None, 'arch': None}
+ assert json.loads(rt_hm.runtimes_file.read_text())[RUNTIME]['local']['status'] == 'Not Installed'
+
+
+def test_list_runtimes_verified_by_the_remote_md5(rt_hm):
+ (rt_hm.libs_dir / RUNTIME).write_bytes(DATA)
+ rt_hm.list_runtimes()
+
+ assert local(rt_hm) == {'status': 'Verified', 'md5': MD5, 'arch': 'aarch64'}
+
+
+def test_list_runtimes_verified_by_an_md5_file(rt_hm):
+ (rt_hm.libs_dir / RUNTIME).write_bytes(b"other")
+ (rt_hm.libs_dir / (RUNTIME + ".md5")).write_text(hashlib.md5(b"other").hexdigest() + " " + RUNTIME + "\n")
+ rt_hm.list_runtimes()
+
+ assert local(rt_hm)['status'] == 'Verified'
+
+
+def test_list_runtimes_md5_file_mismatch_is_unverified(rt_hm):
+ (rt_hm.libs_dir / RUNTIME).write_bytes(DATA)
+ (rt_hm.libs_dir / (RUNTIME + ".md5")).write_text("0" * 32)
+ rt_hm.list_runtimes()
+
+ assert local(rt_hm)['status'] == 'Unverified'
+
+
+def test_list_runtimes_broken(rt_hm):
+ (rt_hm.libs_dir / RUNTIME).write_bytes(b"corrupted")
+ rt_hm.list_runtimes()
+
+ assert local(rt_hm)['status'] == 'Broken'
+
+
+def test_list_runtimes_deleted_since(rt_hm):
+ (rt_hm.libs_dir / RUNTIME).write_bytes(DATA)
+ rt_hm.list_runtimes()
+ (rt_hm.libs_dir / RUNTIME).unlink()
+
+ rt_hm.list_runtimes()
+ assert local(rt_hm)['status'] == 'Not Installed'
+
+
+def test_list_runtimes_upgrades_the_old_remote_layout(rt_hm):
+ rt_hm.runtimes_info["old.squashfs"] = {"name": "Old", "remote": {"name": "Old", "md5": "x", "url": "u", "size": 1}}
+ rt_hm.list_runtimes()
+
+ assert rt_hm.runtimes_info["old.squashfs"]['remote'] == {"aarch64": {"name": "Old", "md5": "x", "url": "u", "size": 1}}
+
+
+def test_list_runtimes_nothing_changed_doesnt_save(rt_hm, monkeypatch):
+ rt_hm.list_runtimes()
+ saved = []
+ monkeypatch.setattr(rt_hm, "save_config", lambda: saved.append(1))
+
+ rt_hm.list_runtimes()
+ assert saved == []
+
+
+################################################################################
+## check_runtime
+@pytest.mark.parametrize("runtime", [None, 5, ["x"]])
+def test_check_runtime_not_a_name(rt_hm, runtime):
+ assert rt_hm.check_runtime(runtime) == 255
+
+
+@pytest.mark.parametrize("in_install, boxes", [(False, 1), (True, 0)])
+def test_check_runtime_path_is_rejected(rt_hm, callback, in_install, boxes):
+ assert rt_hm.check_runtime("../evil.squashfs", in_install=in_install) == 255
+ assert len(callback.message_boxes) == boxes
+
+
+@pytest.mark.parametrize("in_install, boxes", [(False, 1), (True, 0)])
+def test_check_runtime_unknown(rt_hm, callback, in_install, boxes):
+ assert rt_hm.check_runtime("nothing", in_install=in_install) == 255
+ assert len(callback.message_boxes) == boxes
+
+
+def test_check_runtime_downloads(rt_hm, callback):
+ ## The .squashfs is optional.
+ assert rt_hm.check_runtime("gmtoolkit") == 0
+
+ assert (rt_hm.libs_dir / RUNTIME).read_bytes() == DATA
+ assert local(rt_hm) == {'arch': 'aarch64', 'md5': MD5, 'status': 'Verified'}
+ assert callback.message_boxes[-1].startswith("Successfully downloaded")
+ assert json.loads(rt_hm.runtimes_file.read_text())[RUNTIME]['local']['status'] == 'Verified'
+
+
+def test_check_runtime_creates_the_libs_dir(rt_hm):
+ import shutil
+ shutil.rmtree(str(rt_hm.libs_dir))
+
+ assert rt_hm.check_runtime(RUNTIME, in_install=True) == 0
+ assert (rt_hm.libs_dir / RUNTIME).is_file()
+
+
+def test_check_runtime_verified(rt_hm, callback):
+ (rt_hm.libs_dir / RUNTIME).write_bytes(DATA)
+
+ assert rt_hm.check_runtime(RUNTIME) == 0
+ assert rt_hm.runtime_downloads == []
+ assert callback.message_boxes[-1].startswith("Verified ")
+ ## Verifying reports progress, then clears it.
+ assert callback.progress_calls[-1] == (None, None, None, None)
+
+
+def test_check_runtime_verified_in_install(rt_hm, callback):
+ (rt_hm.libs_dir / RUNTIME).write_bytes(DATA)
+
+ assert rt_hm.check_runtime(RUNTIME, in_install=True) == 0
+ assert callback.message_boxes == []
+ assert any(m.startswith("Verified ") for m in callback.messages)
+
+
+def test_check_runtime_update_available(rt_hm, callback):
+ ## Verified against an older release's md5: the new one is downloaded.
+ (rt_hm.libs_dir / RUNTIME).write_bytes(b"old build")
+ rt_hm.list_runtimes()
+ rt_hm.runtimes_info[RUNTIME]['local'] = {'status': 'Verified', 'md5': hashlib.md5(b"old build").hexdigest(), 'arch': 'aarch64'}
+
+ assert rt_hm.check_runtime(RUNTIME) == 0
+ assert any(m.startswith("Updating ") for m in callback.messages)
+ assert (rt_hm.libs_dir / RUNTIME).read_bytes() == DATA
+
+
+def test_check_runtime_broken_is_reinstalled(rt_hm, callback):
+ (rt_hm.libs_dir / RUNTIME).write_bytes(b"corrupted")
+
+ assert rt_hm.check_runtime(RUNTIME) == 0
+ assert any("is broken, reinstalling" in m for m in callback.messages)
+ assert (rt_hm.libs_dir / RUNTIME).read_bytes() == DATA
+
+
+def test_check_runtime_unverified_is_reinstalled(rt_hm, callback):
+ ## No md5 known locally, and not the remote's: treated as broken.
+ (rt_hm.libs_dir / RUNTIME).write_bytes(b"unknown build")
+ (rt_hm.libs_dir / (RUNTIME + ".md5")).write_text("0" * 32)
+
+ assert rt_hm.check_runtime(RUNTIME) == 0
+ assert (rt_hm.libs_dir / RUNTIME).read_bytes() == DATA
+
+
+def test_check_runtime_offline(rt_hm, callback):
+ rt_hm.config['offline'] = True
+
+ assert rt_hm.check_runtime(RUNTIME) == 255
+ assert "offline" in callback.message_boxes[-1]
+
+ ## Installed is good enough when nothing can be checked.
+ (rt_hm.libs_dir / RUNTIME).write_bytes(b"anything")
+ assert rt_hm.check_runtime(RUNTIME) == 0
+ assert rt_hm.runtime_downloads == []
+
+
+def test_check_runtime_wrong_arch(rt_hm, callback):
+ rt_hm.device['primary_arch'] = "riscv64"
+
+ assert rt_hm.check_runtime(RUNTIME) == 255
+ assert "riscv64" in callback.message_boxes[-1]
+
+
+@pytest.mark.parametrize("in_install, boxes", [(False, 1), (True, 0)])
+def test_check_runtime_download_fails(rt_hm, callback, in_install, boxes):
+ rt_hm.runtime_result = None
+
+ assert rt_hm.check_runtime(RUNTIME, in_install=in_install) == 255
+ assert not (rt_hm.libs_dir / RUNTIME).exists()
+ assert len(callback.message_boxes) == boxes
+
+
+def test_check_runtime_download_error_cleans_up(rt_hm, callback, log_errors):
+ rt_hm.runtime_result = OSError("disk full")
+
+ assert rt_hm.check_runtime(RUNTIME) == 255
+ assert not (rt_hm.libs_dir / RUNTIME).exists()
+ assert any("disk full" in message for message in log_errors)
+
+
+def test_check_runtime_cancelled(rt_hm, callback, monkeypatch):
+ monkeypatch.setattr(type(callback), "was_cancelled", property(lambda self: True), raising=False)
+
+ assert rt_hm.check_runtime(RUNTIME) == 255
+ assert not (rt_hm.libs_dir / RUNTIME).exists()
+
+
+def test_check_runtime_cancel_event_cleans_up(rt_hm):
+ rt_hm.runtime_result = CancelEvent()
+
+ assert rt_hm.check_runtime(RUNTIME, in_install=True) == 255
+ assert not (rt_hm.libs_dir / RUNTIME).exists()
+
+
+################################################################################
+## Featured ports
+def write_featured(hm, data):
+ (hm.cfg_dir / "featured_ports.json").write_text(data if isinstance(data, str) else json.dumps(data))
+
+
+def test_featured_ports_none(online_hm):
+ (online_hm.cfg_dir / "featured_ports.json").unlink()
+ assert online_hm.featured_ports() == []
+
+
+@pytest.mark.parametrize("data", ["", "garbage", "{}", "null", "5"])
+def test_featured_ports_bad_file(online_hm, data):
+ write_featured(online_hm, data)
+ assert online_hm.featured_ports() == []
+
+
+def test_featured_ports_makes_its_folder(online_hm):
+ import shutil
+ shutil.rmtree(str(online_hm.cfg_dir / "featured_ports"))
+ online_hm.featured_ports()
+
+ assert (online_hm.cfg_dir / "featured_ports").is_dir()
+
+
+def test_featured_ports_list(online_hm):
+ (online_hm.cfg_dir / "featured_ports" / "best.png").write_bytes(b"png")
+ write_featured(online_hm, [
+ {"name": "Best", "description": "The best.", "image": "images/best.png", "ports": ["sprout.zip", "2048plus.zip", "missing.zip"]},
+ ])
+
+ featured = online_hm.featured_ports()
+
+ assert len(featured) == 1
+ assert featured[0]['name'] == "Best"
+ assert featured[0]['type'] == "ports"
+ assert featured[0]['image'] == str(online_hm.cfg_dir / "featured_ports" / "best.png")
+ assert list(featured[0]['ports']) == ["sprout.zip", "2048plus.zip"]
+
+
+def test_featured_ports_skips_bad_items(online_hm, log_errors):
+ write_featured(online_hm, [
+ "not a dict",
+ {"description": "no name", "ports": ["sprout.zip"]},
+ {"name": "", "ports": ["sprout.zip"]},
+ {"name": "Old", "deprecated": True, "ports": ["sprout.zip"]},
+ {"name": "No ports"},
+ {"name": "Bad ports", "ports": "sprout.zip"},
+ {"name": "Empty", "ports": []},
+ {"name": "Unknown", "ports": ["missing.zip"]},
+ {"name": "Incompatible", "ports": ["starfighter.zip"], "image": "missing.png"},
+ {"name": "Good", "ports": ["sprout.zip"], "image": ""},
+ ])
+ online_hm.device['capabilities'].append("lowres")
+
+ featured = online_hm.featured_ports(pre_load=True)
+
+ assert [item['name'] for item in featured] == ["Good"]
+ assert featured[0]['image'] == ""
+ assert featured[0]['description'] == ""
+ for error in ("Missing name", "Missing ports info", "bad ports item", "unknown port missing.zip"):
+ assert any(error in message for message in log_errors), error
+
+
+def test_featured_ports_errors_only_on_preload(online_hm, log_errors):
+ write_featured(online_hm, [{"name": "No ports"}])
+ online_hm.featured_ports()
+ assert log_errors == []
+
+
+def test_featured_ports_categories(online_hm, log_errors):
+ write_featured(online_hm, [
+ {"name": "Genres", "type": "category", "description": "By genre", "children": [
+ {"name": "Puzzles", "ports": ["2048plus.zip"]},
+ {"name": "Nested", "type": "category", "children": [
+ {"name": "Platformers", "ports": ["sprout.zip"]},
+ ]},
+ {"name": "Empty nest", "type": "category", "children": []},
+ ]},
+ {"name": "Empty", "type": "category", "children": [{"name": "Nothing", "ports": ["missing.zip"]}]},
+ {"name": "Broken", "type": "category", "children": "nope"},
+ ])
+
+ featured = online_hm.featured_ports(pre_load=True)
+
+ assert [item['name'] for item in featured] == ["Genres"]
+ genres = featured[0]
+ assert genres['type'] == "category" and genres['description'] == "By genre"
+ assert [child['name'] for child in genres['children']] == ["Puzzles", "Nested"]
+ assert list(genres['children'][1]['children'][0]['ports']) == ["sprout.zip"]
+
+ assert any("featured_ports[0].children[2]" not in message and "children must be a list" in message for message in log_errors)
+ assert any("featured_ports[1].children[0]['ports'][0]: unknown port" in message for message in log_errors)
diff --git a/tests/test_main.py b/tests/test_main.py
new file mode 100644
index 00000000..8dc58b2a
--- /dev/null
+++ b/tests/test_main.py
@@ -0,0 +1,205 @@
+# SPDX-License-Identifier: MIT
+#
+# pugwash.main.main(): flags, logging, offline detection, the update check, and the
+# frontend refresh afterwards. SDL allows one GUI per process, so the GUI and
+# HarbourMaster here are stand-ins.
+
+import contextlib
+from types import SimpleNamespace
+
+import pytest
+
+import harbourmaster
+
+
+class FakeGUI:
+ made = []
+
+ def __init__(self, first_scene=None, force_theme=None):
+ self.first_scene = first_scene
+ self.force_theme = force_theme
+ self.hm = None
+ self.calls = []
+ FakeGUI.made.append(self)
+
+ @contextlib.contextmanager
+ def enable_cancellable(self, value):
+ yield
+
+ def run_task(self, func, *args, **kwargs):
+ return func(*args, **kwargs)
+
+ def apply_button_mapping(self):
+ self.calls.append('apply_button_mapping')
+
+ def do_fifo_control(self, config, argv):
+ self.calls.append(('fifo', argv))
+
+ def run(self):
+ self.calls.append('run')
+
+ def message_box(self, text, *args, **kwargs):
+ self.calls.append(('box', text))
+
+ def request_frontend_refresh(self):
+ self.calls.append('refresh')
+
+ def quit(self):
+ self.calls.append('quit')
+
+
+@pytest.fixture
+def main(pugwash, monkeypatch, tmp_path):
+ import pugwash.main as main
+
+ FakeGUI.made = []
+ tools = tmp_path / "tools"
+ (tools / "PortMaster").mkdir(parents=True)
+
+ hms = []
+
+ def make_hm(config, temp_dir=None, callback=None):
+ platform = SimpleNamespace(ES_NAME=main.ES_NAME, ports_changed=lambda: main.PORTS_CHANGED, ROMS_REFRESH_TEXT="")
+ hm = SimpleNamespace(config=dict(config), platform=platform)
+ hms.append(hm)
+ return hm
+
+ removed, added = [], []
+ monkeypatch.setattr(main, "PortMasterGUI", FakeGUI)
+ monkeypatch.setattr(main, "HarbourMaster", make_hm)
+ monkeypatch.setattr(main, "get_ip_address", lambda: "192.168.0.2")
+ monkeypatch.setattr(main, "portmaster_check_update", lambda pm, config, temp_dir: False)
+ monkeypatch.setattr(main.logger, "remove", lambda handler=None: removed.append(handler))
+ monkeypatch.setattr(main.logger, "add", lambda *args, **kwargs: added.append(args[0]) or len(added) + 100)
+ monkeypatch.setattr(main.console, "do_color", lambda mode=None: main.COLOURS.append(mode))
+ monkeypatch.setattr(harbourmaster, "HM_TOOLS_DIR", tools)
+ monkeypatch.setattr(main, "LOG_FILE_HANDLE", 7)
+
+ main.ES_NAME = None
+ main.PORTS_CHANGED = False
+ main.COLOURS = []
+ main.hms = hms
+ main.removed = removed
+ main.added = added
+ main.tools = tools
+ return main
+
+
+def run(main, *args):
+ return main.main(["pugwash", *args])
+
+
+def test_runs_the_gui(main):
+ assert run(main) == 0
+
+ pm = FakeGUI.made[-1]
+ assert pm.calls == ['run', 'quit']
+ assert main.hms[-1].config['offline'] is False
+
+
+def test_flags(main):
+ assert run(main, "--quiet", "--no-check", "--no-log", "--no-colour", "--weird") == 0
+
+ config = main.hms[-1].config
+ assert config['quiet'] and config['no-check'] and config['no-log']
+ assert 'weird' not in config
+ assert main.COLOURS == [False]
+ ## --no-log drops the log file.
+ assert 7 in main.removed
+
+
+def test_force_colour(main):
+ run(main, "--force-colour")
+ assert main.COLOURS == [True]
+
+
+def test_double_dash(main):
+ run(main, "--", "--quiet")
+ assert main.hms[-1].config['quiet'] is False
+
+
+def test_debug_flag_file(main):
+ (main.tools / "PortMaster" / "debug_all_the_things_flag").write_text("")
+ run(main)
+
+ assert main.hms[-1].config['debug'] is True
+ assert 0 in main.removed
+
+
+def test_no_log_without_a_log_file(main, monkeypatch):
+ ## logger.remove(None) removes every handler.
+ monkeypatch.setattr(main, "LOG_FILE_HANDLE", None)
+ assert run(main, "--no-log") == 0
+ assert None not in main.removed
+
+
+def test_quiet_logs_errors_only(main):
+ import sys
+ run(main, "--quiet")
+ assert 0 in main.removed and sys.stderr in main.added
+
+
+def test_offline_without_a_network(main, monkeypatch):
+ monkeypatch.setattr(main, "get_ip_address", lambda: None)
+ run(main)
+ assert main.hms[-1].config['offline'] is True
+
+
+def test_fifo_control(main):
+ assert run(main, "fifo_control", "/tmp/pipe", "/tmp/done") == 0
+
+ pm = FakeGUI.made[-1]
+ assert pm.force_theme == "default_theme"
+ assert pm.calls == ['apply_button_mapping', ('fifo', ['/tmp/pipe', '/tmp/done']), 'quit']
+ assert pm.hm is main.hms[-1]
+
+
+def test_fifo_control_without_harbourmaster(main):
+ run(main, "--no-harbour", "fifo_control", "pipe", "done")
+
+ assert FakeGUI.made[-1].hm is None
+ assert main.hms == []
+
+
+def test_reboot_file_skips_the_gui(main):
+ (main.tools / "PortMaster" / ".pugwash-reboot").write_text("")
+ run(main)
+ assert FakeGUI.made[-1].calls == ['quit']
+
+
+@pytest.fixture
+def on_device(main, monkeypatch):
+ monkeypatch.setattr(harbourmaster, "HM_TESTING", False)
+ return main
+
+
+def test_update_installed_quits(on_device, monkeypatch):
+ monkeypatch.setattr(on_device, "portmaster_check_update", lambda pm, config, temp_dir: True)
+
+ assert run(on_device) == 0
+ assert FakeGUI.made[-1].calls == ['quit']
+ assert on_device.hms == []
+
+
+def test_no_check_skips_the_update_check(on_device, monkeypatch):
+ def fail(*args):
+ raise AssertionError("checked")
+
+ monkeypatch.setattr(on_device, "portmaster_check_update", fail)
+ run(on_device, "--no-check")
+
+
+@pytest.mark.parametrize("es_name, changed, expected", [
+ (None, True, []),
+ ("emulationstation", False, []),
+ ("emulationstation", True, ['refresh']),
+ ("show-refresh", True, ['box']),
+ ])
+def test_frontend_refresh(on_device, es_name, changed, expected):
+ on_device.ES_NAME = es_name
+ on_device.PORTS_CHANGED = changed
+
+ run(on_device)
+
+ calls = [call if isinstance(call, str) else call[0] for call in FakeGUI.made[-1].calls]
+ assert calls == ['run'] + expected + ['quit']
diff --git a/tests/test_platform_hooks.py b/tests/test_platform_hooks.py
new file mode 100644
index 00000000..5e1aa4b9
--- /dev/null
+++ b/tests/test_platform_hooks.py
@@ -0,0 +1,844 @@
+# SPDX-License-Identifier: MIT
+#
+# The per-CFW platform hooks (harbourmaster/platform/), against a fake device root:
+# each module's Path is swapped for one that puts absolute device paths under tmp.
+
+import json
+import os
+from pathlib import Path
+
+import pytest
+
+from harbourmaster import platform as hm_platform
+from harbourmaster.platform import arkos, base, batocera, emuelec, jelos, miyoo, muos, retrodeck, trimui
+from harbourmaster.util import CancelEvent
+
+from conftest import basic_port_files, write_port_zip
+
+
+class DeviceRoot:
+ """Absolute paths outside tmp_path land in `root`, `Path.home()` is root/home."""
+ def __init__(self, tmp_path):
+ self.tmp_path = tmp_path
+ self.root = tmp_path / "root"
+ self.root.mkdir()
+
+ def __call__(self, *args):
+ path = Path(*args)
+ if path.is_absolute() and self.tmp_path not in path.parents:
+ return self.root / path.relative_to("/")
+
+ return path
+
+ def home(self):
+ return self.root / "home"
+
+ def path(self, absolute):
+ return self.root / absolute.lstrip("/")
+
+ def write(self, absolute, text=""):
+ path = self.path(absolute)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text)
+ return path
+
+ def mkdir(self, absolute):
+ path = self.path(absolute)
+ path.mkdir(parents=True, exist_ok=True)
+ return path
+
+
+@pytest.fixture
+def device(tmp_path, monkeypatch):
+ fake = DeviceRoot(tmp_path)
+ for module in (muos, trimui, miyoo, jelos, batocera, retrodeck):
+ monkeypatch.setattr(module, "Path", fake)
+
+ return fake
+
+
+@pytest.fixture
+def pm_dir(hm):
+ """PortMaster's own folder with every CFW's control.txt and launcher stub."""
+ pm_dir = hm.tools_dir / "PortMaster"
+ for cfw in ("muos", "trimui", "miyoo", "spruce", "batocera", "knulli", "retrodeck"):
+ (pm_dir / cfw).mkdir(parents=True, exist_ok=True)
+ (pm_dir / cfw / "control.txt").write_text(f"# {cfw} control\n")
+ (pm_dir / cfw / "PortMaster.txt").write_text(f"# {cfw} launcher\n")
+
+ return pm_dir
+
+
+GAMEINFO = """
+
+
+ ./Test Port.sh
+ Test Port
+ First line of the description.
+Second line.
+ ./testport/cover.png
+
+
+"""
+
+
+@pytest.fixture
+def gameinfo(hm):
+ ports_dir = hm.ports_dir
+ (ports_dir / "testport").mkdir(exist_ok=True)
+ (ports_dir / "testport" / "cover.png").write_bytes(b"cover")
+ (ports_dir / "testport" / "screenshot.png").write_bytes(b"shot")
+ (ports_dir / "Test Port.sh").write_text("#!/bin/bash\n")
+
+ path = ports_dir / "testport" / "gameinfo.xml"
+ path.write_text(GAMEINFO)
+ return path
+
+
+################################################################################
+## The registry
+def test_every_platform_is_a_platform(hm):
+ for name, platform_class in hm_platform.HM_PLATFORMS.items():
+ assert name == name.lower()
+ assert issubclass(platform_class, base.PlatformBase)
+
+
+def use_cfw(monkeypatch, cfw):
+ from harbourmaster import hardware
+ monkeypatch.setenv("CFW_NAME", cfw)
+ hardware._CACHED_DICT = None
+
+
+@pytest.mark.parametrize("cfw, expected", [
+ ("muOS", muos.PlatformmuOS),
+ ("TrimUI", trimui.PlatformTrimUI),
+ ("spruce", miyoo.PlatformSpruce),
+ ("darkosre", arkos.PlatformdArkOS),
+ ("unofficialos", emuelec.PlatformUOS),
+ ("something new", base.PlatformBase),
+ ])
+def test_platform_by_cfw_name(make_hm, monkeypatch, cfw, expected, device, pm_dir, hm_dirs):
+ ## A fresh config folder, so first_run runs too: against the fake root.
+ import shutil
+ shutil.rmtree(str(pm_dir / "config"))
+ use_cfw(monkeypatch, cfw)
+
+ hm = make_hm()
+ assert type(hm.platform) is expected
+ assert hm.platform_name == (cfw.lower() if expected is not base.PlatformBase else "default")
+
+
+################################################################################
+## PlatformBase
+def test_base_added_and_removed_ports(hm):
+ platform = base.PlatformBase(hm)
+ assert not platform.ports_changed()
+
+ platform.port_install("a.zip", {}, [], [])
+ assert platform.added_ports == {"a.zip"}
+ assert platform.ports_changed()
+
+ ## Uninstalling what was installed this run cancels out.
+ platform.port_uninstall("a.zip", {}, [])
+ assert platform.added_ports == set() and platform.removed_ports == set()
+ assert not platform.ports_changed()
+
+ platform.port_uninstall("b.zip", {}, [])
+ assert platform.removed_ports == {"b.zip"}
+ platform.port_install("b.zip", {}, [], [])
+ assert platform.removed_ports == set() and platform.added_ports == set()
+
+
+def test_base_defaults(hm):
+ platform = base.PlatformBase(hm)
+
+ assert platform.gamelist_file() is None
+ assert platform.get_gcd_modes() == ()
+ assert platform.get_gcd_mode() is None
+ platform.set_gcd_mode("xbox")
+ platform.runtime_install("x.squashfs", [])
+ platform.loaded()
+ platform.do_move_ports()
+ platform.first_run()
+ platform.portmaster_install([])
+
+
+def test_base_gamelist_add_missing_gameinfo(hm, tmp_path):
+ platform = base.PlatformTesting(hm)
+ platform.gamelist_add(tmp_path / "missing.xml")
+ assert not (hm.scripts_dir / "gamelist.xml").exists()
+
+
+@pytest.mark.parametrize("platform_class", [muos.PlatformmuOS, trimui.PlatformTrimUI])
+def test_special_gamelist_errors_dont_escape(hm, platform_class, device, gameinfo, monkeypatch, log_errors):
+ ## Like a real gamelist: a failure updating the frontend's own catalogue must not fail the install.
+ platform = platform_class(hm)
+
+ def boom(*args, **kwargs):
+ raise OSError("no space left on device")
+
+ device.mkdir("/opt/graphicsmagick")
+ for folder in ("box", "preview", "text"):
+ device.mkdir(f"/run/muos/storage/info/catalogue/External - Ports/{folder}")
+ for folder in ("Imgs/PORTS", "Roms/PORTS"):
+ device.mkdir(f"/mnt/SDCARD/{folder}")
+
+ monkeypatch.setattr(base.shutil, "copy", boom)
+ monkeypatch.setattr(muos.shutil, "copy", boom)
+ monkeypatch.setattr(trimui.shutil, "copy", boom)
+
+ platform.gamelist_add(gameinfo)
+ assert any("no space left on device" in message for message in log_errors)
+
+
+@pytest.mark.parametrize("error", [CancelEvent, KeyboardInterrupt])
+def test_special_gamelist_cancel_still_escapes(hm, error):
+ platform = muos.PlatformmuOS(hm)
+
+ with pytest.raises(error):
+ with platform.gamelist_backup():
+ raise error()
+
+
+################################################################################
+## GCD modes (ArkOS, AmberELEC, ...)
+@pytest.fixture
+def gcd(hm):
+ backup = hm.tools_dir / "PortMaster" / ".Backup"
+ backup.mkdir(parents=True)
+ (backup / "donottouch.txt").write_text("# Standard Layout\n")
+ (backup / "donottouch_x.txt").write_text("# Xbox 360 Layout\n")
+ return emuelec.PlatformAmberELEC(hm)
+
+
+def test_gcd_mode_defaults_to_standard(gcd, hm):
+ assert gcd.get_gcd_modes() == ('standard', 'xbox')
+ assert gcd.get_gcd_mode() == 'standard'
+ assert hm.cfg_data['gcd-mode'] == 'standard'
+
+
+def test_gcd_mode_detected_from_the_file(gcd, hm):
+ (hm.tools_dir / "PortMaster" / "gamecontrollerdb.txt").write_text("# Xbox 360 Layout\nstuff\n")
+ assert gcd.get_gcd_mode() == 'xbox'
+
+ gcd.loaded()
+ assert gcd.WANT_XBOX_FIX is True
+
+
+def test_gcd_mode_bad_config_value(gcd, hm):
+ hm.cfg_data['gcd-mode'] = 'nonsense'
+ assert gcd.get_gcd_mode() == 'standard'
+
+
+def test_set_gcd_mode(gcd, hm):
+ gamecontroller = hm.tools_dir / "PortMaster" / "gamecontrollerdb.txt"
+
+ gcd.set_gcd_mode('xbox')
+ assert gamecontroller.read_text() == "# Xbox 360 Layout\n"
+ assert hm.cfg_data['gcd-mode'] == 'xbox'
+ assert json.loads(hm.cfg_file.read_text())['gcd-mode'] == 'xbox'
+
+ gcd.set_gcd_mode('standard')
+ assert gamecontroller.read_text() == "# Standard Layout\n"
+
+
+@pytest.mark.parametrize("mode", [None, "", "rainbow"])
+def test_set_gcd_mode_ignores_bad_modes(gcd, hm, mode):
+ gcd.set_gcd_mode(mode)
+ assert not (hm.tools_dir / "PortMaster" / "gamecontrollerdb.txt").exists()
+
+
+def test_set_gcd_mode_missing_file(gcd, hm):
+ (hm.tools_dir / "PortMaster" / ".Backup" / "donottouch_x.txt").unlink()
+ gcd.set_gcd_mode('xbox')
+ assert 'gcd-mode' not in hm.cfg_data
+
+
+def test_harbourmaster_gcd_passthrough(hm, gcd):
+ hm.platform = gcd
+ hm.set_gcd_mode('xbox')
+ assert hm.get_gcd_mode() == 'xbox'
+ assert hm.get_gcd_modes() == ('standard', 'xbox')
+
+
+################################################################################
+## ArkOS
+@pytest.mark.parametrize("good, expected", [
+ (None, "bad"),
+ ("old launcher", "bad"),
+ ("new pmsplash launcher", "new pmsplash launcher"),
+ ])
+def test_arkos_moves_a_misplaced_launcher(hm, good, expected):
+ (hm.ports_dir / "PortMaster.sh").write_text("bad")
+ if good is not None:
+ (hm.tools_dir / "PortMaster.sh").write_text(good)
+
+ arkos.PlatformArkOS(hm)
+
+ assert not (hm.ports_dir / "PortMaster.sh").exists()
+ assert (hm.tools_dir / "PortMaster.sh").read_text() == expected
+
+
+def test_arkos_nothing_to_move(hm):
+ arkos.PlatformArkOS(hm)
+ assert not (hm.tools_dir / "PortMaster.sh").exists()
+ assert arkos.PlatformArkOS(hm).gamelist_file() == hm.ports_dir / "gamelist.xml"
+
+
+def test_emuelec_gamelists(hm):
+ assert emuelec.PlatformEmuELEC(hm).gamelist_file() == hm.scripts_dir / "gamelist.xml"
+ assert emuelec.PlatformUOS(hm).gamelist_file() == hm.ports_dir / "gamelist.xml"
+ assert emuelec.PlatformAmberELEC(hm).gamelist_file() == hm.ports_dir / "gamelist.xml"
+
+
+################################################################################
+## muOS
+def test_muos_portmaster_install(hm, device, pm_dir):
+ bash_files = []
+ (pm_dir / "tasksetter").write_text("old")
+
+ muos.PlatformmuOS(hm).first_run()
+ muos.PlatformmuOS(hm).portmaster_install(bash_files)
+
+ assert (pm_dir / "control.txt").read_text() == "# muos control\n"
+ assert device.path("/roms/ports/PortMaster/control.txt").read_text() == "# muos control\n"
+ assert (pm_dir / "PortMaster.sh").read_text() == "# muos launcher\n"
+ assert bash_files == [pm_dir / "PortMaster.sh"]
+ assert (pm_dir / "tasksetter").read_text() == ""
+
+
+def muos_port_info(name="testport.zip", script="Test Port.sh"):
+ return {"name": name, "files": {"port.json": "testport/port.json", script: script, "testport/": "testport/"}}
+
+
+def test_muos_port_install_application(hm, device):
+ app_dir = device.mkdir("/run/muos/storage/application")
+ (hm.ports_dir / "testport").mkdir()
+ (hm.ports_dir / "testport" / "mux_launch.txt").write_text("#!/bin/sh\nlaunch\n")
+
+ platform = muos.PlatformmuOS(hm)
+ platform.port_install("testport.zip", muos_port_info(), [], [])
+
+ assert (app_dir / "Test Port" / "mux_launch.sh").read_text() == "#!/bin/sh\nlaunch\n"
+ assert (app_dir / "Test Port" / platform.MUOSAPP_PM_MARKER).read_text() == "Test Port"
+
+ platform.port_uninstall("testport.zip", muos_port_info(), [])
+ assert not (app_dir / "Test Port").exists()
+
+
+def test_muos_port_install_without_script_uses_the_port_name(hm, device):
+ app_dir = device.mkdir("/run/muos/storage/application")
+ (hm.ports_dir / "testport").mkdir()
+ (hm.ports_dir / "testport" / "mux_launch.txt").write_text("x")
+
+ info = {"name": "testport.zip", "files": {"port.json": "testport/port.json"}}
+ muos.PlatformmuOS(hm).port_install("testport.zip", info, [], [])
+
+ assert (app_dir / "testport" / "mux_launch.sh").is_file()
+
+
+def test_muos_port_install_not_an_application(hm, device):
+ app_dir = device.mkdir("/run/muos/storage/application")
+ (hm.ports_dir / "testport").mkdir()
+
+ muos.PlatformmuOS(hm).port_install("testport.zip", muos_port_info(), [], [])
+ assert list(app_dir.iterdir()) == []
+
+
+def test_muos_port_install_without_the_application_folder(hm, device):
+ (hm.ports_dir / "testport").mkdir()
+ (hm.ports_dir / "testport" / "mux_launch.txt").write_text("x")
+
+ muos.PlatformmuOS(hm).port_install("testport.zip", muos_port_info(), [], [])
+ assert not device.path("/run/muos/storage/application").exists()
+
+
+def test_muos_port_install_copy_error_is_logged(hm, device, monkeypatch, log_errors):
+ device.mkdir("/run/muos/storage/application")
+ (hm.ports_dir / "testport").mkdir()
+ (hm.ports_dir / "testport" / "mux_launch.txt").write_text("x")
+
+ def fail(*args, **kwargs):
+ raise OSError("read-only")
+
+ monkeypatch.setattr(muos.shutil, "copy2", fail)
+ muos.PlatformmuOS(hm).port_install("testport.zip", muos_port_info(), [], [])
+
+ assert any("read-only" in message for message in log_errors)
+
+
+def test_muos_uninstall_leaves_apps_it_didnt_make(hm, device):
+ app_dir = device.mkdir("/run/muos/storage/application/Test Port")
+ (app_dir / "mux_launch.sh").write_text("someone else's")
+
+ muos.PlatformmuOS(hm).port_uninstall("testport.zip", muos_port_info(), [])
+ assert (app_dir / "mux_launch.sh").is_file()
+
+
+def test_muos_uninstall_error_is_ignored(hm, device, monkeypatch):
+ app_dir = device.mkdir("/run/muos/storage/application/Test Port")
+ (app_dir / muos.PlatformmuOS.MUOSAPP_PM_MARKER).write_text("Test Port")
+
+ def fail(*args, **kwargs):
+ raise OSError("busy")
+
+ monkeypatch.setattr(muos.shutil, "rmtree", fail)
+ muos.PlatformmuOS(hm).port_uninstall("testport.zip", muos_port_info(), [])
+
+
+@pytest.mark.parametrize("catalogue", [
+ "/run/muos/storage/info/catalogue/External - Ports",
+ "/mnt/mmc/MUOS/info/catalogue/External - Ports",
+ ])
+def test_muos_gamelist_add(hm, device, gameinfo, catalogue):
+ device.write("/usr/bin/magick")
+ for folder in ("box", "preview", "text"):
+ device.mkdir(f"{catalogue}/{folder}")
+
+ platform = muos.PlatformmuOS(hm)
+ assert platform.gamelist_file() is base.SPECIAL_GAMELIST_CODE
+ platform.gamelist_add(gameinfo)
+
+ root = device.path(catalogue)
+ assert (root / "box" / "Test Port-pre.png").read_bytes() == b"cover"
+ assert (root / "preview" / "Test Port-pre.png").read_bytes() == b"shot"
+ assert (root / "text" / "Test Port.txt").read_text() == "First line of the description.\n"
+ assert platform.ports_changed()
+
+
+def test_muos_gamelist_add_jpg_screenshot_and_missing_image(hm, device, gameinfo):
+ catalogue = "/run/muos/storage/info/catalogue/External - Ports"
+ device.mkdir("/opt/graphicsmagick")
+ for folder in ("box", "preview", "text"):
+ device.mkdir(f"{catalogue}/{folder}")
+
+ (hm.ports_dir / "testport" / "screenshot.png").unlink()
+ (hm.ports_dir / "testport" / "screenshot.jpg").write_bytes(b"jpg")
+
+ muos.PlatformmuOS(hm).gamelist_add(gameinfo)
+ ## Named after the cover's suffix.
+ assert device.path(catalogue + "/preview/Test Port-pre.png").read_bytes() == b"jpg"
+
+ (hm.ports_dir / "testport" / "cover.png").unlink()
+ for path in device.path(catalogue).rglob("*.*"):
+ path.unlink()
+
+ muos.PlatformmuOS(hm).gamelist_add(gameinfo)
+ assert not device.path(catalogue + "/box/Test Port-pre.png").exists()
+ assert device.path(catalogue + "/text/Test Port.txt").is_file()
+
+
+def test_muos_gamelist_add_needs_an_image_tool(hm, device, gameinfo):
+ platform = muos.PlatformmuOS(hm)
+ platform.gamelist_add(gameinfo)
+
+ assert not platform.ports_changed()
+
+
+def test_muos_gamelist_add_makes_missing_catalogue_folders(hm, device, gameinfo):
+ ## A catalogue without a preview folder failed the whole port install.
+ device.write("/usr/bin/magick")
+ catalogue = "/run/muos/storage/info/catalogue/External - Ports"
+ device.mkdir(catalogue)
+
+ muos.PlatformmuOS(hm).gamelist_add(gameinfo)
+
+ assert device.path(catalogue + "/box/Test Port-pre.png").is_file()
+ assert device.path(catalogue + "/preview/Test Port-pre.png").is_file()
+
+
+def test_muos_gamelist_add_without_a_catalogue(hm, device, gameinfo):
+ device.write("/usr/bin/magick")
+ muos.PlatformmuOS(hm).gamelist_add(gameinfo)
+
+ ## None made up.
+ assert not device.path("/mnt/mmc").exists()
+ assert not device.path("/run/muos/storage/info").exists()
+
+
+def test_muos_gamelist_add_missing_file(hm, device, tmp_path):
+ muos.PlatformmuOS(hm).gamelist_add(tmp_path / "missing.xml")
+
+
+################################################################################
+## TrimUI
+@pytest.fixture
+def trimui_dirs(device):
+ for folder in ("/mnt/SDCARD/Roms/PORTS", "/mnt/SDCARD/Imgs/PORTS", "/mnt/SDCARD/Ports"):
+ device.mkdir(folder)
+
+ return device
+
+
+def test_trimui_portmaster_install(hm, trimui_dirs, pm_dir):
+ bash_files = []
+ trimui.PlatformTrimUI(hm).first_run()
+ trimui.PlatformTrimUI(hm).portmaster_install(bash_files)
+
+ assert (pm_dir / "control.txt").read_text() == "# trimui control\n"
+ assert trimui_dirs.path("/roms/ports/PortMaster/control.txt").is_file()
+ assert (hm.tools_dir / "launch.sh").read_text() == "# trimui launcher\n"
+ assert bash_files == [hm.tools_dir / "launch.sh"]
+ assert (pm_dir / "tasksetter").is_file()
+
+
+def test_trimui_port_install_roms_mode(hm, trimui_dirs):
+ script = hm.scripts_dir / "Test Port.sh"
+ script.write_text("#!/bin/bash\n")
+ nested = hm.scripts_dir / "testport" / "inner.sh"
+ nested.parent.mkdir()
+ nested.write_text("x")
+
+ platform = trimui.PlatformTrimUI(hm)
+ platform.port_install("testport.zip", {}, [script, nested, hm.scripts_dir / "testport" / "data.txt"], [])
+
+ roms = trimui_dirs.path("/mnt/SDCARD/Roms/PORTS")
+ assert (roms / "Test Port.sh").read_text() == "#!/bin/bash\n"
+ ## Only scripts right in the scripts folder.
+ assert not (roms / "inner.sh").exists()
+
+ platform.port_uninstall("testport.zip", {}, ["Test Port.sh", "testport/"])
+ assert not (roms / "Test Port.sh").exists()
+
+
+def test_trimui_port_install_outside_the_scripts_dir(hm, trimui_dirs, tmp_path):
+ other = tmp_path / "elsewhere"
+ other.mkdir()
+ script = other / "x.sh"
+ script.write_text("x")
+
+ trimui.PlatformTrimUI(hm).port_install("x.zip", {}, [script], [])
+ assert not trimui_dirs.path("/mnt/SDCARD/Roms/PORTS/x.sh").exists()
+
+
+def test_trimui_add_script_same_file(hm, trimui_dirs):
+ ## The scripts folder *is* the roms folder: nothing to copy.
+ roms = trimui_dirs.path("/mnt/SDCARD/Roms/PORTS")
+ script = roms / "Same.sh"
+ script.write_text("x")
+
+ trimui.PlatformTrimUI(hm).add_port_script(script)
+ assert script.read_text() == "x"
+
+
+def test_trimui_ports_mode(hm, trimui_dirs):
+ hm.cfg_data['trimui-port-mode'] = 'ports'
+ script = hm.scripts_dir / "Test Port.sh"
+ script.write_text("x")
+
+ platform = trimui.PlatformTrimUI(hm)
+ platform.add_port_script(script)
+
+ port_dir = trimui_dirs.path("/mnt/SDCARD/Ports/portmaster-test.port")
+ config = json.loads((port_dir / "config.json").read_text())
+ assert config['label'] == "Test Port"
+ assert config['package'] == "portmaster-test.port"
+ assert config['launch'] == str(script).replace(" ", "\\ ")
+
+ platform.remove_port_script(script)
+ assert not port_dir.exists()
+
+
+def test_trimui_gamelist_add_roms_mode(hm, trimui_dirs, gameinfo):
+ platform = trimui.PlatformTrimUI(hm)
+ platform.gamelist_add(gameinfo)
+
+ assert trimui_dirs.path("/mnt/SDCARD/Imgs/PORTS/Test Port-pre.png").read_bytes() == b"cover"
+ assert trimui_dirs.path("/mnt/SDCARD/Roms/PORTS/Test Port.sh").is_file()
+
+
+def test_trimui_gamelist_add_ports_mode(hm, trimui_dirs, gameinfo):
+ hm.cfg_data['trimui-port-mode'] = 'ports'
+ trimui.PlatformTrimUI(hm).gamelist_add(gameinfo)
+
+ port_dir = trimui_dirs.path("/mnt/SDCARD/Ports/portmaster-test.port")
+ assert json.loads((port_dir / "config.json").read_text())['label'] == "Test Port"
+ assert (port_dir / "icon-pre.png").read_bytes() == b"cover"
+
+
+def test_trimui_gamelist_add_partial_entries(hm, trimui_dirs, gameinfo):
+ gameinfo.write_text("""
+ ./Missing.sh
+ ./Test Port.sh
+ ./Test Port.sh./testport/none.png
+""")
+ hm.cfg_data['trimui-port-mode'] = 'ports'
+
+ trimui.PlatformTrimUI(hm).gamelist_add(gameinfo)
+
+ port_dir = trimui_dirs.path("/mnt/SDCARD/Ports/portmaster-test.port")
+ ## No name: the script's.
+ assert json.loads((port_dir / "config.json").read_text())['label'] == "Test Port"
+ assert not (port_dir / "icon-pre.png").exists()
+
+
+def test_trimui_gamelist_add_missing_file(hm, trimui_dirs, tmp_path):
+ platform = trimui.PlatformTrimUI(hm)
+ platform.gamelist_add(tmp_path / "missing.xml")
+ assert not platform.ports_changed()
+
+
+@pytest.mark.parametrize("mode", ["roms", "ports"])
+def test_trimui_do_move_ports(hm, trimui_dirs, gameinfo, mode):
+ roms = trimui_dirs.path("/mnt/SDCARD/Roms/PORTS")
+ ports = trimui_dirs.path("/mnt/SDCARD/Ports")
+ (roms / "Stale.sh").write_text("x")
+ (ports / "portmaster-stale").mkdir()
+ (ports / "portmaster-file").write_text("not a folder")
+ (ports / "someone-elses").mkdir()
+
+ hm.cfg_data['trimui-port-mode'] = mode
+ trimui.PlatformTrimUI(hm).do_move_ports()
+
+ assert (ports / "someone-elses").is_dir()
+ assert (ports / "portmaster-file").is_file()
+ if mode == "roms":
+ assert not (ports / "portmaster-stale").exists()
+ assert (roms / "Test Port.sh").is_file()
+ assert (roms / "Stale.sh").is_file()
+ else:
+ assert not (roms / "Stale.sh").exists()
+ assert (ports / "portmaster-test.port" / "config.json").is_file()
+
+
+################################################################################
+## Miyoo and spruce
+def test_miyoo_portmaster_install(hm, device, pm_dir):
+ bash_files = []
+ miyoo.PlatformMiyoo(hm).first_run()
+ miyoo.PlatformMiyoo(hm).portmaster_install(bash_files)
+
+ assert (pm_dir / "control.txt").read_text() == "# miyoo control\n"
+ assert (pm_dir / "PortMaster.sh").read_text() == "# miyoo launcher\n"
+ assert device.path("/root/.local/share/PortMaster/control.txt").read_text() == "# miyoo control\n"
+ assert bash_files == [pm_dir / "PortMaster.sh"]
+
+
+@pytest.mark.parametrize("xdg", [True, False])
+def test_spruce_portmaster_install(hm, device, pm_dir, monkeypatch, tmp_path, xdg):
+ if xdg:
+ monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "xdg"))
+ control = tmp_path / "xdg" / "PortMaster" / "control.txt"
+ else:
+ monkeypatch.delenv("XDG_DATA_HOME", raising=False)
+ control = device.path("/home/.local/share/PortMaster/control.txt")
+
+ platform = miyoo.PlatformSpruce(hm)
+ platform.first_run()
+ platform.portmaster_post_install()
+
+ assert (pm_dir / "control.txt").read_text() == "# spruce control\n"
+ assert (pm_dir / "PortMaster.sh").read_text() == "# spruce launcher\n"
+ assert control.read_text() == "# spruce control\n"
+
+
+################################################################################
+## JELOS / ROCKNIX
+def test_jelos_portmaster_install(hm, device, monkeypatch):
+ system = device.mkdir("/usr/config/PortMaster")
+ for name in ("control.txt", "gamecontrollerdb.txt", "mapper.txt", "oga_controls", "oga_controls_settings.txt"):
+ (system / name).write_text(name)
+
+ import shutil
+ copytree = shutil.copytree
+ monkeypatch.setattr(jelos.shutil, "copytree", lambda src, dst: copytree(str(device(src)), str(dst)))
+ (hm.tools_dir / "PortMaster").mkdir(exist_ok=True)
+
+ platform = jelos.PlatformROCKNIX(hm)
+ platform.first_run()
+
+ pm_dir = hm.tools_dir / "PortMaster"
+ for name in ("control.txt", "gamecontrollerdb.txt", "mapper.txt", "oga_controls", "oga_controls_settings.txt"):
+ assert (pm_dir / name).read_text() == name
+
+ assert device.path("/storage/.config/PortMaster/control.txt").is_file()
+ assert platform.gamelist_file() == hm.ports_dir / "gamelist.xml"
+ assert platform.ES_NAME == "weston"
+
+
+################################################################################
+## Batocera / knulli settings
+@pytest.fixture
+def batocera_conf(device, monkeypatch):
+ files = [str(device.path(path)) for path in batocera.PlatformBatocera.BATOCERA_CONFIG_FILES]
+ monkeypatch.setattr(batocera.PlatformBatocera, "BATOCERA_CONFIG_FILES", files)
+ return device
+
+
+@pytest.mark.parametrize("files, key, expected", [
+ ({"/userdata/system/batocera.conf": "a=1\n"}, "a", "1"),
+ ({"/userdata/system/knulli.conf": "a=knulli\n", "/userdata/system/batocera.conf": "a=batocera\n"}, "a", "knulli"),
+ ({"/userdata/system/system.conf": "# a=comment\n\nnonsense\n b = spaced \n"}, "b", "spaced"),
+ ({"/userdata/system/batocera.conf": "a=1\n"}, "missing", "default"),
+ ({}, "a", "default"),
+ ])
+def test_batocera_settings_get(hm, batocera_conf, files, key, expected):
+ device = batocera_conf
+ for path, text in files.items():
+ device.write(path, text)
+
+ platform = batocera.PlatformBatocera(hm)
+ assert platform.batocera_settings_get(key, "default") == expected
+ ## Remembered.
+ assert platform.batocera_settings_get(key, "default") == expected
+
+
+@pytest.mark.parametrize("settings, expected", [
+ (None, False),
+ ('', True),
+ ('', False),
+ ('', False),
+ ])
+def test_batocera_invert_buttons(hm, device, settings, expected):
+ if settings is not None:
+ device.write("/userdata/system/configs/emulationstation/es_settings.cfg", settings)
+
+ platform = batocera.PlatformBatocera(hm)
+ assert platform.get_invert_buttons_value() is expected
+
+ platform.loaded()
+ assert platform.WANT_SWAP_BUTTONS is (not expected)
+
+
+@pytest.mark.parametrize("conf, invert, xbox, swap", [
+ ('ports["PortMaster.sh"].xbox_layout=0\n', None, False, True),
+ ('ports["PortMaster.sh"].xbox_layout=1\n', None, False, False),
+ ('ports.xbox_layout=0\n', None, False, True),
+ ('', "true", True, False),
+ ('', "false", False, True),
+ ])
+def test_knulli_loaded(hm, batocera_conf, conf, invert, xbox, swap):
+ device = batocera_conf
+ device.write("/userdata/system/batocera.conf", conf)
+ if invert is not None:
+ device.write("/userdata/system/configs/emulationstation/es_settings.cfg",
+ f'')
+
+ platform = batocera.PlatformKnulli(hm)
+ platform.loaded()
+
+ assert platform.WANT_XBOX_FIX is xbox
+ assert platform.WANT_SWAP_BUTTONS is swap
+
+
+def test_batocera_first_run(hm, device, pm_dir):
+ platform = batocera.PlatformBatocera(hm)
+ platform.first_run()
+
+ assert (hm.tools_dir / ".pugwash-reboot").is_file()
+ assert (pm_dir / "control.txt").read_text() == "# batocera control\n"
+ assert platform.gamelist_file() == hm.ports_dir / "gamelist.xml"
+
+
+def test_batocera_move_launcher_error_is_logged(hm, device, pm_dir, monkeypatch, log_errors):
+ (pm_dir / "PortMaster.sh").write_text("x")
+
+ def fail(*args, **kwargs):
+ raise OSError("read-only")
+
+ monkeypatch.setattr(batocera, "replace_file", fail)
+ batocera.PlatformBatocera(hm)._move_launcher()
+
+ assert any("read-only" in message for message in log_errors)
+
+
+################################################################################
+## RetroDECK
+def write_rd_json(device, paths, where="/var/config/retrodeck/retrodeck.json"):
+ return device.write(where, json.dumps({"paths": paths}))
+
+
+def test_retrodeck_json_config(hm, device, tmp_path):
+ write_rd_json(device, {"rd_home_path": "/rd", "roms_path": "/roms", "ports_path": "/ports"})
+
+ config = retrodeck.PlatformRetroDECK(hm).get_rdconfig()
+ assert config == {"rdhome": device("/rd"), "roms_folder": device("/roms"), "ports_folder": device("/ports")}
+
+
+def test_retrodeck_json_config_defaults(hm, device):
+ write_rd_json(device, {"rd_home_path": "/rd"}, where="/home/.var/app/net.retrodeck.retrodeck/config/retrodeck/retrodeck.json")
+
+ config = retrodeck.PlatformRetroDECK(hm).get_rdconfig()
+ assert config['roms_folder'] == device("/rd/roms")
+ assert config['ports_folder'] == device("/rd/PortMaster")
+
+
+@pytest.mark.parametrize("text", ['[]', '{"paths": {}}', 'garbage'])
+def test_retrodeck_bad_json_config(hm, device, text, log_errors):
+ device.write("/var/config/retrodeck/retrodeck.json", text)
+
+ platform = retrodeck.PlatformRetroDECK(hm)
+ assert platform.get_rdconfig() is None
+ assert platform.gamelist_file() is None
+
+
+def test_retrodeck_cfg_config(hm, device):
+ device.write("/var/config/retrodeck/retrodeck.cfg", "[paths]\nrdhome=/rd\nroms_folder=/r\nmedia_folder=/m\n")
+
+ config = retrodeck.PlatformRetroDECK(hm).get_rdconfig()
+ assert config['rdhome'] == device("/rd")
+ assert config['roms_folder'] == device("/r")
+ assert config['media_folder'] == device("/m")
+ assert config['ports_folder'] == device("/rd/PortMaster")
+
+
+def test_retrodeck_cfg_without_rdhome(hm, device, log_errors):
+ device.write("/home/.var/app/net.retrodeck.retrodeck/config/retrodeck/retrodeck.cfg", "roms_folder=/r\n")
+
+ assert retrodeck.PlatformRetroDECK(hm).get_rdconfig() is None
+ assert any("rdhome" in message for message in log_errors)
+
+
+def test_retrodeck_no_config_at_all(hm, device, gameinfo):
+ ## Neither file: it opened the missing .cfg anyway, failing every install with a gameinfo.xml.
+ platform = retrodeck.PlatformRetroDECK(hm)
+
+ assert platform.get_rdconfig() is None
+ assert platform.gamelist_file() is None
+ platform.gamelist_add(gameinfo)
+
+
+def test_retrodeck_gamelist_file(hm, device, tmp_path):
+ write_rd_json(device, {"rd_home_path": str(tmp_path / "rd")})
+
+ gamelist = retrodeck.PlatformRetroDECK(hm).gamelist_file()
+ assert gamelist == tmp_path / "rd" / "ES-DE" / "gamelists" / "portmaster" / "gamelist.xml"
+ assert gamelist.parent.is_dir()
+
+
+def test_retrodeck_portmaster_install(hm, device, pm_dir, tmp_path):
+ scripts = hm.scripts_dir
+ (scripts / "PortMaster.sh").symlink_to(tmp_path / "nowhere")
+ bash_files = []
+
+ platform = retrodeck.PlatformRetroDECK(hm)
+ platform.first_run()
+ platform.portmaster_install(bash_files)
+
+ assert (pm_dir / "control.txt").read_text() == "# retrodeck control\n"
+ assert (pm_dir / "PortMaster.sh").read_text() == "# retrodeck launcher\n"
+ assert not (scripts / "PortMaster.sh").is_symlink()
+ assert (scripts / "PortMaster.sh").read_text() == "# retrodeck launcher\n"
+ assert bash_files == [scripts / "PortMaster.sh"]
+
+
+################################################################################
+## End to end: an install on muOS with the catalogue
+def test_muos_install_fills_the_catalogue(make_hm, hm_dirs, device, monkeypatch, pm_dir):
+ use_cfw(monkeypatch, "muOS")
+ hm = make_hm()
+ assert type(hm.platform) is muos.PlatformmuOS
+
+ device.write("/usr/bin/magick")
+ catalogue = "/run/muos/storage/info/catalogue/External - Ports"
+ for folder in ("box", "preview", "text"):
+ device.mkdir(f"{catalogue}/{folder}")
+
+ files = basic_port_files()
+ files["testport/gameinfo.xml"] = GAMEINFO
+ files["testport/cover.png"] = "cover"
+ zip_file = write_port_zip(hm_dirs['temp_dir'] / "testport.zip", files)
+
+ assert hm.install_port(str(zip_file)) == 0
+ assert device.path(catalogue + "/box/Test Port-pre.png").read_text() == "cover"
+ assert device.path(catalogue + "/text/Test Port.txt").read_text().startswith("First line")
diff --git a/tests/test_sdl_helpers.py b/tests/test_sdl_helpers.py
new file mode 100644
index 00000000..74d7b2e2
--- /dev/null
+++ b/tests/test_sdl_helpers.py
@@ -0,0 +1,355 @@
+# SPDX-License-Identifier: MIT
+#
+# pugwash.sdl's plain helpers: Rect maths, theme area parsing (NamedRects), colours,
+# dict merging, autoscroll, timers and animations.
+
+from types import SimpleNamespace
+
+import pytest
+
+
+pytestmark = pytest.mark.sdl
+
+
+@pytest.fixture
+def geometry(pugwash):
+ from pugwash.sdl import geometry
+ return geometry
+
+
+@pytest.fixture
+def helpers(pugwash):
+ from pugwash.sdl import helpers
+ return helpers
+
+
+################################################################################
+## Rect
+def test_rect_basics(geometry):
+ Rect = geometry.Rect
+ rect = Rect(10.7, 20.2, 100, 50)
+
+ assert rect.tuple() == (10, 20, 100, 50)
+ assert repr(rect) == "Rect(10, 20, 100, 50)"
+ assert len(rect) == 4 and list(rect) == [10, 20, 100, 50]
+ assert rect[-1] == 50
+
+ copy = rect.copy()
+ copy.move(5, 5)
+ assert rect.topleft == (10, 20) and copy.topleft == (15, 25)
+ assert rect.moved(-10, -20).topleft == (0, 0)
+
+ rect.update(1, 2, 3, 4)
+ assert rect.tuple() == (1, 2, 3, 4)
+
+
+def test_rect_edges(geometry):
+ rect = geometry.Rect(0, 0, 100, 50)
+ rect.right = 300
+ rect.bottom = 200
+ assert (rect.left, rect.top) == (200, 150)
+
+ rect.left, rect.top = 5, 6
+ rect.centerx, rect.centery = 50, 25
+ assert rect.topleft == (0, 0)
+ assert (rect.w, rect.h) == (100, 50)
+
+
+def test_rect_size_keeps_the_centre(geometry):
+ rect = geometry.Rect(0, 0, 100, 100)
+ rect.size = (50, 20)
+ assert rect.tuple() == (25, 40, 50, 20)
+
+
+def test_rect_scale(geometry):
+ rect = geometry.Rect(0, 0, 100, 100) * 0.5
+ assert rect.tuple() == (25, 25, 50, 50)
+
+
+def test_rect_inflate(geometry):
+ rect = geometry.Rect(10, 10, 100, 100)
+ rect.inflate(-20)
+ assert rect.tuple() == (20, 20, 80, 80)
+
+ assert geometry.Rect(10, 10, 100, 100).inflated(20, 10).tuple() == (0, 5, 120, 110)
+ assert geometry.Rect(10, 10, 100, 100).inflated(4).tuple() == (8, 8, 104, 104)
+
+
+def test_rect_from(geometry):
+ import sdl2
+ assert geometry.Rect.from_corners(10, 20, 110, 70).tuple() == (10, 20, 100, 50)
+ assert geometry.Rect.from_sdl(sdl2.SDL_Rect(1, 2, 3, 4)).tuple() == (1, 2, 3, 4)
+
+ sdl_rect = geometry.Rect(1, 2, 3, 4).sdl()
+ assert (sdl_rect.x, sdl_rect.y, sdl_rect.w, sdl_rect.h) == (1, 2, 3, 4)
+
+
+@pytest.mark.parametrize("image, area, expected", [
+ ## Wide image in a square: full width, centred vertically.
+ ((0, 0, 200, 100), (0, 0, 100, 100), (0, 25, 100, 50)),
+ ## Tall image in a wide area: full height, centred across.
+ ((0, 0, 50, 100), (10, 10, 200, 100), (85, 10, 50, 100)),
+ ## Same shape: fills it.
+ ((0, 0, 10, 10), (5, 5, 40, 40), (5, 5, 40, 40)),
+ ])
+def test_rect_fit(geometry, image, area, expected):
+ Rect = geometry.Rect
+ assert Rect(*image).fitted(Rect(*area)).tuple() == expected
+
+ rect = Rect(*image)
+ rect.fit(Rect(*area))
+ assert rect.tuple() == expected
+
+
+@pytest.mark.parametrize("rect, area, expected", [
+ ((10, 10, 50, 50), (0, 0, 100, 100), (10, 10, 50, 50)),
+ ((-10, -10, 50, 50), (0, 0, 100, 100), (0, 0, 40, 40)),
+ ((80, 80, 50, 50), (0, 0, 100, 100), (80, 80, 20, 20)),
+ ((-10, 20, 200, 10), (0, 0, 100, 100), (0, 20, 100, 10)),
+ ((200, 200, 10, 10), (0, 0, 100, 100), (200, 200, 0, 0)),
+ ((20, 200, 10, 10), (0, 0, 100, 100), (20, 200, 0, 0)),
+ ((-50, 10, 10, 10), (0, 0, 100, 100), (-50, 10, 0, 0)),
+ ((10, -50, 10, 10), (0, 0, 100, 100), (10, -50, 0, 0)),
+ ])
+def test_rect_clip(geometry, rect, area, expected):
+ Rect = geometry.Rect
+ assert Rect(*rect).clip(Rect(*area)).tuple() == expected
+
+
+def test_rect_setitem(geometry):
+ rect = geometry.Rect(0, 0, 1, 1)
+ for i, value in enumerate((5, 6, 7, 8)):
+ rect[i] = value
+
+ assert rect.tuple() == (5, 6, 7, 8)
+
+ rect[-4] = 9
+ assert rect.x == 9
+
+ with pytest.raises(geometry.GUIValueError):
+ rect[0] = 1.5
+
+ with pytest.raises(geometry.GUIValueError):
+ rect[4] = 1
+
+
+def test_point(geometry):
+ point = geometry.Point(3, 4)
+ assert (point.x, point.y) == (3, 4)
+
+
+################################################################################
+## Theme areas
+@pytest.fixture
+def rects(geometry):
+ rects = geometry.NamedRects((0, 0, 640, 480))
+ rects.make_rect('root', 'panel', [0.5, 0.0, 1.0, 0.5])
+ return rects
+
+
+@pytest.mark.parametrize("value, expected", [
+ ## Fractions of the parent.
+ ([0.0, 0.0, 1.0, 1.0], (320, 0, 320, 240)),
+ ([0.5, 0.5, 1.0, 1.0], (480, 120, 160, 120)),
+ ## Pixels from the parent's corner, the far corner absolute.
+ ([10, 10, 400, 100], (330, 10, 70, 90)),
+ ## Negative: from the parent's far edge.
+ ([-100, -40, -10, -10], (540, 200, 90, 30)),
+ ])
+def test_named_rect(rects, value, expected):
+ assert rects.make_rect('panel', 'child', value).tuple() == expected
+ assert rects._rects['child'].tuple() == expected
+
+
+def test_named_rect_unknown_parent_is_root(rects):
+ assert rects.make_rect('nothing', None, [0.0, 0.0, 0.5, 0.5]).tuple() == (0, 0, 320, 240)
+ assert 'None' not in rects._rects and None not in rects._rects
+
+
+def test_named_rect_copy(rects):
+ copy = rects.copy()
+ copy.make_rect('root', 'extra', [0, 0, 10, 10])
+
+ assert 'panel' in copy._rects
+ assert 'extra' not in rects._rects
+
+
+@pytest.mark.parametrize("value, error", [
+ ([0, 0, 10], "GUIThemeError"),
+ ([0, 0, 10, 10, 10], "GUIThemeError"),
+ ([0, "a", 10, 10], "GUIThemeError"),
+ (5, "TypeError"),
+ ])
+def test_named_rect_bad(rects, geometry, value, error):
+ with pytest.raises(Exception) as exc:
+ rects.make_rect('root', 'x', value)
+
+ assert type(exc.value).__name__ == error
+
+
+################################################################################
+## Colours and dicts
+@pytest.mark.parametrize("code, expected", [
+ ("#fff", (255, 255, 255)),
+ ("#F00", (255, 0, 0)),
+ ("#0f08", (0, 255, 0, 136)),
+ ("#123456", (0x12, 0x34, 0x56)),
+ ("12345678", (0x12, 0x34, 0x56, 0x78)),
+ ## Bad length or not hex: white, never a crash.
+ ("#12345", (255, 255, 255)),
+ ("#ggg", (255, 255, 255)),
+ ("#zz00zz", (255, 255, 255)),
+ ])
+def test_hex_color_decode(helpers, code, expected):
+ assert helpers.hex_color_decode(code) == expected
+
+
+def test_hex_color_decode_cache_cant_be_changed(helpers):
+ with pytest.raises(TypeError):
+ helpers.hex_color_decode("#abc")[0] = 0
+
+
+def test_deep_update_and_merge(helpers):
+ base = {"a": 1, "b": {"c": 2, "d": {"e": 3}}}
+ merged = helpers.deep_merge(base, {"b": {"d": {"f": 4}}, "g": 5})
+
+ assert merged == {"a": 1, "b": {"c": 2, "d": {"e": 3, "f": 4}}, "g": 5}
+ ## deep_merge leaves the original alone.
+ assert base == {"a": 1, "b": {"c": 2, "d": {"e": 3}}}
+
+ helpers.deep_update(base, {"a": {"x": 1}})
+ assert base["a"] == {"x": 1}
+
+
+def test_deep_print(helpers, capsys):
+ helpers.deep_print({"a": 1, "b": {"c": 2}}, name="root")
+ assert capsys.readouterr().out == "root\n a: 1\n b\n c: 2\n"
+
+
+def test_range_list(helpers):
+ assert helpers.range_list(50, 0, 100, 10) == ["50", "60", "70", "80", "90", "100", "0", "10", "20", "30", "40"]
+ assert helpers.range_list(0, 0, 20, 10) == ["0", "10", "20"]
+
+
+@pytest.mark.parametrize("text, align, amount, vertical, expected", [
+ ## Fits: nothing to scroll.
+ ((0, 0, 50, 50), 'topleft', 30, True, (0, 0, 0, 'topleft')),
+ ## Taller than the area: scrolls up, at most by the overflow.
+ ((0, 0, 50, 300), 'topleft', 30, True, (0, -30, 200, 'topleft')),
+ ((0, 0, 50, 300), 'topleft', 999, True, (0, -200, 200, 'topleft')),
+ ((0, 0, 50, 300), 'bottomleft', 10, True, (0, 110, 200, 'bottomleft')),
+ ## Centred text that overflows scrolls from the top.
+ ((0, 0, 50, 300), 'center', 10, True, (50, -10, 200, 'topcenter')),
+ ((0, 0, 300, 50), 'topleft', 30, False, (-30, 0, 200, 'topleft')),
+ ((0, 0, 300, 50), 'topright', 30, False, (130, 0, 200, 'topright')),
+ ((0, 0, 300, 50), 'center', 5, False, (-5, 50, 200, 'midleft')),
+ ])
+def test_autoscroll_text(geometry, helpers, text, align, amount, vertical, expected):
+ Rect = geometry.Rect
+ assert helpers.autoscroll_text(Rect(*text), Rect(0, 0, 100, 100), align, amount, vertical) == expected
+
+
+################################################################################
+## Timers and animations
+@pytest.fixture
+def ticks(monkeypatch):
+ import sdl2
+ now = [1000]
+ monkeypatch.setattr(sdl2, "SDL_GetTicks64", lambda: now[0])
+ return now
+
+
+def test_timer(pugwash, ticks):
+ from pugwash.sdl.timing import Timer
+ timer = Timer()
+
+ assert timer.since("a") == 0
+ ticks[0] += 250
+ assert timer.since("a") == 250
+
+ ## First check: not elapsed unless run_first.
+ assert timer.elapsed("b", 100) is False
+ assert timer.elapsed("c", 100, run_first=True) is True
+ ticks[0] += 99
+ assert timer.elapsed("b", 100) is False
+ ticks[0] += 1
+ assert timer.elapsed("b", 100) is True
+ ## And reset.
+ assert timer.elapsed("b", 100) is False
+
+ timer.clear()
+ assert timer.since("a") == 0
+
+
+class FakeGui:
+ def __init__(self):
+ self.data = {}
+
+ def set_data(self, key, value):
+ self.data[key] = value
+
+
+def test_animation_options_are_checked(pugwash):
+ from pugwash.sdl.timing import AnimationManager
+ manager = AnimationManager(FakeGui())
+
+ manager.add_animation("bad", {'max-frames': -1, 'skip-frames': 9, 'start': 9, 'update': 5, 'loop': "yes", 'reset-on-scene': 1})
+ bad = manager._animations["bad"]
+ assert (bad['max-frames'], bad['skip-frames'], bad['start'], bad['update'], bad['loop'], bad['reset-on-scene']) == (1, 1, 0, 100, True, True)
+
+ manager.add_animation("good", {'max-frames': 10, 'skip-frames': 2, 'start': 3, 'update': 200, 'loop': False, 'reset-on-scene': False})
+ good = manager._animations["good"]
+ assert (good['max-frames'], good['skip-frames'], good['start'], good['update'], good['loop'], good['reset-on-scene']) == (10, 2, 3, 200, False, False)
+ assert manager._gui.data["animation.good"] == "000"
+
+
+def test_animation_loops(pugwash, ticks):
+ from pugwash.sdl.timing import AnimationManager
+ gui = FakeGui()
+ manager = AnimationManager(gui)
+ manager.add_animation("spin", {'max-frames': 3, 'update': 100})
+
+ frames = []
+ for step in range(5):
+ manager.update_animations()
+ frames.append(gui.data["animation.spin"])
+ ticks[0] += 100
+
+ assert frames == ["000", "001", "002", "000", "001"]
+
+
+def test_animation_stops(pugwash, ticks):
+ from pugwash.sdl.timing import AnimationManager
+ gui = FakeGui()
+ manager = AnimationManager(gui)
+ manager.add_animation("once", {'max-frames': 2, 'update': 100, 'loop': False})
+
+ for step in range(5):
+ manager.update_animations()
+ ticks[0] += 100
+
+ assert gui.data["animation.once"] == "001"
+ assert manager._animations["once"]['done']
+
+ ## Not before its time.
+ manager.add_animation("slow", {'max-frames': 5, 'update': 1000})
+ manager.update_animations()
+ ticks[0] += 999
+ manager.update_animations()
+ assert gui.data["animation.slow"] == "000"
+
+
+def test_animation_scene_change(pugwash, ticks):
+ from pugwash.sdl.timing import AnimationManager
+ gui = FakeGui()
+ manager = AnimationManager(gui)
+ manager.add_animation("resets", {'max-frames': 5, 'update': 100})
+ manager.add_animation("keeps", {'max-frames': 5, 'update': 100, 'reset-on-scene': False})
+
+ for step in range(3):
+ manager.update_animations()
+ ticks[0] += 100
+
+ manager.change_scene()
+ assert gui.data["animation.resets"] == "000"
+ assert gui.data["animation.keeps"] == "002"
diff --git a/tests/test_source.py b/tests/test_source.py
index d6c19410..daf74bd7 100644
--- a/tests/test_source.py
+++ b/tests/test_source.py
@@ -8,6 +8,8 @@
from harbourmaster import source as hm_source
from harbourmaster.util import net as hm_net
+REAL_FETCH_JSON = hm_net.fetch_json
+
@pytest.fixture
def pm_source(hm, ports_json_data, monkeypatch):
@@ -83,6 +85,59 @@ def test_update_failure_keeps_going(hm, monkeypatch):
assert source.ports == []
+def test_update_again_fetches_again(pm_source, ports_json_data):
+ ## "Update ports" after the startup update emptied the list until a restart. It
+ ## fetches again: a release may have been made since PortMaster started.
+ ports_json_data['ports']['sprout.zip']['attr']['title'] = "Sprout, the new release"
+ pm_source.update()
+
+ assert pm_source.fetched == [pm_source._config['url']] * 2
+ assert sorted(pm_source.ports) == sorted(ports_json_data['ports'])
+ assert pm_source.port_info("sprout.zip")['attr']['title'] == "Sprout, the new release"
+
+
+def test_failed_update_keeps_the_cached_ports(pm_source, make_hm, ports_json_data, monkeypatch):
+ ## Due an update, but the network isn't really there: the cache is still shown.
+ config = json.loads(pm_source._file_name.read_text())
+ config['last_checked'] = "2020-01-01T00:00:00"
+ pm_source._file_name.write_text(json.dumps(config))
+
+ def timeout(*args, **kwargs):
+ raise hm_net.requests.exceptions.ReadTimeout("read timed out")
+
+ monkeypatch.setattr(hm_net, "fetch_json", REAL_FETCH_JSON)
+ monkeypatch.setattr(hm_net.requests, "get", timeout)
+ source = make_hm(offline=False, **{'no-check': False}).sources['pm']
+
+ assert sorted(source.ports) == sorted(ports_json_data['ports'])
+ assert source.port_download_url("sprout.zip") == ports_json_data['ports']['sprout.zip']['source']['url']
+
+
+class FakeResponse:
+ status_code = 200
+
+ def json(self):
+ raise ValueError("Expecting value: line 1 column 1 (char 0)")
+
+
+@pytest.mark.parametrize("error", [
+ hm_net.requests.exceptions.ReadTimeout("read timed out"),
+ hm_net.requests.exceptions.SSLError("certificate is not yet valid"),
+ hm_net.requests.exceptions.ConnectionError("no route to host"),
+ ])
+def test_fetch_network_errors_are_none(monkeypatch, error):
+ def get(*args, **kwargs):
+ raise error
+
+ monkeypatch.setattr(hm_net.requests, "get", get)
+ assert hm_net.fetch_json("https://example.com/ports.json") is None
+
+
+def test_fetch_json_bad_json_is_none(monkeypatch):
+ monkeypatch.setattr(hm_net.requests, "get", lambda *args, **kwargs: FakeResponse())
+ assert hm_net.fetch_json("https://example.com/ports.json") is None
+
+
def test_raw_download_md5_unavailable(tmp_path, callback, monkeypatch):
## Used to crash with NameError on the undefined `r` while reporting this.
monkeypatch.setattr(hm_net, "fetch_text", lambda url: None)
diff --git a/tests/test_theme_engine.py b/tests/test_theme_engine.py
new file mode 100644
index 00000000..7fca99b5
--- /dev/null
+++ b/tests/test_theme_engine.py
@@ -0,0 +1,237 @@
+# SPDX-License-Identifier: MIT
+#
+# pugwash.theme: requirement tags in theme keys, merging, the installed and
+# downloadable themes list, and broken custom themes (they must never stop
+# PortMaster from starting).
+
+import json
+from types import SimpleNamespace
+
+import pytest
+
+import harbourmaster
+
+
+
+pytestmark = pytest.mark.sdl
+
+
+@pytest.fixture
+def theme(pugwash):
+ from pugwash import theme
+ return theme
+
+
+################################################################################
+## Requirements and merging
+@pytest.mark.parametrize("text, strict, expected", [
+ ("area", False, ("area", [])),
+ ("area", True, ("area", None)),
+ ("area[640x480]", False, ("area", ["640x480"])),
+ ("area[ 640x480 , power ]", True, ("area", ["640x480", "power"])),
+ ("area[a][b]", False, ("area_bad", [])),
+ ("area[a]x", True, ("area_bad", None)),
+ ])
+def test_extract_requirements(theme, text, strict, expected):
+ assert theme.extract_requirements(text, strict=strict) == expected
+
+
+def test_theme_update_follows_requirements(theme):
+ ## The test device: aarch64, 640x480, power, opengl.
+ result = theme.theme_update({"keep": 1}, {
+ "font-size": 10,
+ "font-size[640x480]": 20,
+ "font-size[1280x720]": 30,
+ "fill[!opengl]": [1, 2, 3],
+ "list": [1, 2],
+ "nested": {"a": 1, "b[power]": 2},
+ })
+
+ assert result == {"keep": 1, "font-size": 20, "list": [1, 2], "nested": {"a": 1, "b": 2}}
+
+
+def test_theme_update_copies_lists(theme):
+ source = {"list": [1, 2]}
+ result = theme.theme_update({}, source)
+ result["list"].append(3)
+
+ assert source == {"list": [1, 2]}
+
+
+def test_theme_merge_leaves_both_alone(theme):
+ target = {"a": {"b": 1}}
+ source = {"a": {"c": 2}}
+
+ assert theme.theme_merge(target, source) == {"a": {"b": 1, "c": 2}}
+ assert target == {"a": {"b": 1}} and source == {"a": {"c": 2}}
+
+
+################################################################################
+## The themes list
+@pytest.fixture
+def engine(theme, hm, monkeypatch):
+ monkeypatch.setattr(harbourmaster, "HM_TOOLS_DIR", hm.tools_dir)
+ (hm.tools_dir / "PortMaster" / "themes").mkdir(parents=True, exist_ok=True)
+ return theme.ThemeEngine(SimpleNamespace(hm=hm))
+
+
+def add_theme(hm, folder, name=None, data=None, text=None, md5=None, screenshot=None):
+ theme_dir = hm.tools_dir / "PortMaster" / "themes" / folder
+ theme_dir.mkdir(parents=True, exist_ok=True)
+ if text is None:
+ text = json.dumps(data if data is not None else {"#info": {"name": name or folder, "creator": "me", "description": "A theme."}})
+
+ (theme_dir / "theme.json").write_text(text)
+ if md5 is not None:
+ (theme_dir / "theme.md5").write_text(md5 + "\n")
+ if screenshot is not None:
+ (theme_dir / screenshot).write_bytes(b"img")
+
+ return theme_dir
+
+
+def test_themes_list_order(engine, hm):
+ add_theme(hm, "zeta", "Zeta")
+ add_theme(hm, "alpha", "alpha")
+
+ assert list(engine.get_themes_list()) == ["default_theme", "alpha", "zeta"]
+
+
+def test_theme_info(engine, hm):
+ theme_dir = add_theme(hm, "mine", "Mine", md5="abc", screenshot="screenshot.png")
+
+ info = engine.get_theme_info("mine")
+ assert info['name'] == "Mine" and info['creator'] == "me" and info['description'] == "A theme."
+ assert info['md5'] == "abc"
+ assert info['image'] == theme_dir / "screenshot.png"
+ assert info['status'] == "Installed" and info['url'] is None
+
+ assert engine.get_theme_info("default_theme")['md5'] == "d41d8cd98f00b204e9800998ecf8427e"
+
+
+def test_themes_list_with_downloads(engine, hm):
+ add_theme(hm, "same", "Same", md5="111")
+ add_theme(hm, "old", "Old", md5="222")
+ add_theme(hm, "noshot", "No Shot", md5="333")
+
+ downloads = {
+ "same": {"name": "Same", "url": "https://x/same.zip", "md5": "111", "image": None},
+ "old": {"name": "Old", "url": "https://x/old.zip", "md5": "999", "image": None},
+ "noshot": {"name": "No Shot", "url": "https://x/noshot.zip", "md5": "333", "image": "/cache/noshot.png"},
+ "new": {"name": "New", "url": "https://x/new.zip", "md5": "444", "image": None},
+ }
+
+ themes = engine.get_themes_list(downloads)
+
+ assert themes["same"]['status'] == "Installed" and themes["same"]['url'] == "https://x/same.zip"
+ assert themes["old"]['status'] == "Update Available"
+ assert themes["noshot"]['image'] == "/cache/noshot.png"
+ assert themes["new"]['status'] == "Not Installed"
+ ## Its own copy.
+ assert 'status' not in downloads["new"]
+
+
+@pytest.mark.parametrize("text", ['{"#info": {"name": "Bro', "", "[]", "null"])
+def test_broken_theme_is_left_out_of_the_list(engine, hm, text, log_errors):
+ ## A cut-off or hand-edited theme.json took the whole list (Options -> Select Theme) down.
+ add_theme(hm, "good", "Good")
+ add_theme(hm, "broken", text=text)
+
+ assert list(engine.get_themes_list()) == ["default_theme", "good"]
+
+
+@pytest.mark.parametrize("make", [
+ lambda hm: None,
+ lambda hm: (hm.tools_dir / "PortMaster" / "themes" / "chosen").mkdir(parents=True),
+ lambda hm: add_theme(hm, "chosen", text='{"broken'),
+ ])
+def test_current_theme_falls_back(engine, hm, make, log_errors):
+ make(hm)
+ hm.cfg_data['theme'] = "chosen"
+
+ assert engine.get_current_theme() == "default_theme"
+ assert any("default_theme" in message for message in log_errors)
+
+
+def test_current_theme(engine, hm):
+ add_theme(hm, "chosen", "Chosen")
+ hm.cfg_data['theme'] = "chosen"
+ assert engine.get_current_theme() == "chosen"
+
+ hm.cfg_data.pop('theme')
+ assert engine.get_current_theme() == "default_theme"
+
+
+def test_forced_theme(theme, hm):
+ engine = theme.ThemeEngine(SimpleNamespace(hm=hm), force_theme="forced")
+ assert engine.get_current_theme() == "forced"
+
+
+def test_config_without_harbourmaster(theme, hm, monkeypatch):
+ monkeypatch.setattr(harbourmaster, "HM_TOOLS_DIR", hm.tools_dir)
+ hm.cfg_data['theme-scheme'] = "Night"
+ hm.save_config()
+
+ engine = theme.ThemeEngine(SimpleNamespace(hm=None))
+ assert engine.get_current_theme_scheme() == "Night"
+
+
+def test_theme_schemes(engine, hm):
+ add_theme(hm, "schemed", data={"#info": {"name": "S"}, "#schemes": {"#base": {}, "Day": {}, "Night": {}}})
+ assert engine.get_theme_schemes_list("schemed") == ["Day", "Night"]
+ assert engine.get_theme("schemed").screenshot is None
+
+
+def test_theme_missing(theme, tmp_path):
+ with pytest.raises(ValueError):
+ theme.Theme(tmp_path / "nothing")
+
+
+################################################################################
+## A custom theme that loads as JSON but not as a theme
+@pytest.fixture
+def loading(theme, engine, hm, monkeypatch):
+ """Theme.gui_init stood in for: the "Typo" theme fails as a typo in it would."""
+ from pugwash.sdl.errors import GUIThemeError
+ add_theme(hm, "Typo", "Typo")
+ tried = []
+
+ def gui_init(self, gui, color_scheme=None):
+ tried.append(self.theme_dir.name)
+ if self.theme_dir.name == "Typo":
+ raise GUIThemeError("Region area incorrect length")
+
+ return {"loaded": self.theme_dir.name}
+
+ monkeypatch.setattr(theme.Theme, "gui_init", gui_init)
+ engine.gui.resources = SimpleNamespace(remove_path=lambda path: tried.append(("removed", path.name)))
+ engine.tried = tried
+ return engine
+
+
+def test_theme_that_fails_to_load_falls_back(loading, hm, log_errors):
+ ## It stopped PortMaster from starting until the theme was deleted by hand.
+ hm.cfg_data['theme'] = "Typo"
+
+ assert loading.gui_init() == {"loaded": "default_theme"}
+ assert loading.tried == ["Typo", ("removed", "Typo"), "default_theme"]
+ assert loading.get_current_theme() == "default_theme"
+ assert any("using the default theme" in message for message in log_errors)
+
+
+def test_forced_theme_that_fails_to_load_raises(loading):
+ ## The theme editor shows the error rather than another theme.
+ from pugwash.sdl.errors import GUIThemeError
+ loading.force_theme = "Typo"
+
+ with pytest.raises(GUIThemeError):
+ loading.gui_init()
+
+
+def test_default_theme_failing_raises(loading, theme, monkeypatch):
+ def fail(self, gui, color_scheme=None):
+ raise ValueError("broken install")
+
+ monkeypatch.setattr(theme.Theme, "gui_init", fail)
+ with pytest.raises(ValueError):
+ loading.gui_init()
diff --git a/tests/test_update_check.py b/tests/test_update_check.py
new file mode 100644
index 00000000..eadaf339
--- /dev/null
+++ b/tests/test_update_check.py
@@ -0,0 +1,284 @@
+# SPDX-License-Identifier: MIT
+#
+# pugwash.update.portmaster_check_update(): PortMaster's self-update check, and
+# config files broken by a power off while saving.
+
+import datetime
+import json
+
+import pytest
+
+import harbourmaster
+from harbourmaster import util
+
+from conftest import RecordingCallback
+
+
+@pytest.fixture
+def update(pugwash, monkeypatch, tmp_path):
+ import pugwash.update as update
+
+ tools = tmp_path / "updtools"
+ (tools / "PortMaster" / "config").mkdir(parents=True)
+ monkeypatch.setattr(harbourmaster, "HM_TOOLS_DIR", tools)
+ monkeypatch.setattr(update, "PORTMASTER_VERSION", "2026.06.23-1000")
+ monkeypatch.setattr(update, "PORTMASTER_RELEASE_CHANNEL", "stable")
+ monkeypatch.setattr(update, "PORTMASTER_MIN_VERSION", "2024.01.01-0000")
+ monkeypatch.setattr(update, "HarbourMaster", lambda *args, **kwargs: "hm")
+ update.cfg_file = tools / "PortMaster" / "config" / "config.json"
+ update.tools = tools
+ return update
+
+
+class FakePM:
+ def __init__(self, release_info, answer=True):
+ self.release_info = release_info
+ self.answer = answer
+ self.hm = None
+ self.boxes = []
+ self.installs = []
+ self.fetched = []
+
+ def run_task(self, func, *args, **kwargs):
+ if func is harbourmaster.fetch_json:
+ self.fetched.append(args[0])
+ if isinstance(self.release_info, BaseException):
+ raise self.release_info
+
+ return self.release_info
+
+ return func(*args, **kwargs)
+
+ def apply_button_mapping(self):
+ pass
+
+ def message_box(self, text, want_cancel=False):
+ self.boxes.append(text)
+ return self.answer
+
+ def do_install(self, name, url, allow_cancel=True, md5_source=None):
+ self.installs.append((name, url, md5_source))
+
+
+def release(version, channel="stable"):
+ return {channel: {"version": version, "url": f"https://example.com/{version}/PortMaster.zip", "md5": "abc"}}
+
+
+def write_config(update, data):
+ update.cfg_file.write_text(json.dumps(data) if not isinstance(data, str) else data)
+
+
+def config(update):
+ return json.loads(update.cfg_file.read_text())
+
+
+def check(update, pm, **config_values):
+ cfg = {'no-check': False, 'offline': False, **config_values}
+ return update.portmaster_check_update(pm, cfg, None), cfg
+
+
+def test_newer_version_installs(update):
+ pm = FakePM(release("2026.10.18-0059"))
+
+ result, cfg = check(update, pm)
+
+ assert result is True
+ assert "new version of PortMaster (2026.10.18-0059)" in pm.boxes[0]
+ assert pm.installs == [("PortMaster", "https://example.com/2026.10.18-0059/PortMaster.zip", "abc")]
+ assert (update.tools / "PortMaster" / ".pugwash-reboot").is_file()
+ assert config(update)['update_checked']
+ ## Asked under no-check, so the HarbourMaster made for it doesn't update sources.
+ assert cfg['no-check'] is True
+
+
+def test_newer_version_declined(update):
+ pm = FakePM(release("2026.10.18-0059"), answer=False)
+
+ result, cfg = check(update, pm)
+
+ assert result is False
+ assert pm.installs == []
+ assert pm.hm is None
+ assert cfg['no-check'] is False
+
+
+def test_same_version_does_nothing(update):
+ pm = FakePM(release("2026.06.23-1000"))
+
+ assert check(update, pm)[0] is False
+ assert pm.boxes == []
+
+
+def test_checked_recently_doesnt_fetch(update):
+ write_config(update, {"update_checked": datetime.datetime.now().isoformat()})
+ pm = FakePM(release("2099.01.01-0000"))
+
+ assert check(update, pm)[0] is False
+ assert pm.fetched == []
+
+
+def test_checked_long_ago_fetches(update):
+ write_config(update, {"update_checked": "2020-01-01T00:00:00"})
+ pm = FakePM(release("2026.06.23-1000"))
+
+ check(update, pm)
+ assert len(pm.fetched) == 1
+
+
+def test_fetch_error(update):
+ pm = FakePM(OSError("no network"))
+ assert check(update, pm)[0] is None
+
+
+def test_nothing_fetched(update):
+ assert check(update, FakePM(None))[0] is False
+ ## Tried again next time.
+ assert not update.cfg_file.exists()
+
+
+def test_channel_missing_from_version_json(update):
+ pm = FakePM(release("2099.01.01-0000", channel="beta"))
+ assert check(update, pm)[0] is False
+ assert pm.boxes == []
+
+
+def test_wont_go_below_the_minimum_version(update):
+ write_config(update, {"release_channel": "stable", "change_channel": True})
+ pm = FakePM(release("2020.01.01-0000"))
+
+ assert check(update, pm)[0] is False
+ assert pm.boxes == []
+
+
+def test_switching_channel_downgrades(update):
+ write_config(update, {"release_channel": "stable", "change_channel": True})
+ pm = FakePM(release("2026.01.01-0000"))
+
+ class Hm:
+ cfg_data = {'change_channel': True}
+ saved = False
+
+ def save_config(self):
+ self.saved = True
+
+ update.HarbourMaster = lambda *args, **kwargs: Hm()
+
+ assert check(update, pm)[0] is True
+ assert "Do you want to downgrade?" in pm.boxes[0]
+ assert pm.hm.cfg_data['change_channel'] is False and pm.hm.saved
+
+
+def test_older_version_without_a_channel_switch(update):
+ pm = FakePM(release("2026.01.01-0000"))
+ assert check(update, pm)[0] is False
+ assert pm.boxes == []
+
+
+def test_konami_reinstall(update):
+ write_config(update, {"konami": True})
+ pm = FakePM(release("2026.06.23-1000"))
+
+ assert check(update, pm)[0] is True
+ assert "reinstall PortMaster" in pm.boxes[0]
+
+
+def test_unknown_channel_is_reset(update):
+ from pugwash import PORTMASTER_RELEASE_VALUES
+ write_config(update, {"release_channel": "nightly"})
+ pm = FakePM(release("2026.06.23-1000", channel=PORTMASTER_RELEASE_VALUES[-1]))
+
+ check(update, pm)
+ assert config(update)['release_channel'] == PORTMASTER_RELEASE_VALUES[-1]
+
+
+@pytest.mark.parametrize("contents", ["", "{", "\x00\x00\x00", "[1]", "\xff\xfe"])
+def test_broken_config_json(update, contents):
+ update.cfg_file.write_bytes(contents.encode('latin-1'))
+ pm = FakePM(release("2026.10.18-0059"))
+
+ assert check(update, pm)[0] is True
+
+
+@pytest.mark.parametrize("info", [
+ {"stable": {}},
+ {"stable": "2099"},
+ {"stable": {"version": "2099.01.01-0000"}},
+ ["stable"],
+ ])
+def test_bad_version_json(update, info):
+ ## A broken version.json on the server must not stop PortMaster from starting.
+ pm = FakePM(info)
+ assert check(update, pm)[0] is False
+ assert pm.installs == []
+
+
+################################################################################
+## HarbourMaster's own config files
+@pytest.mark.parametrize("contents", [b"", b"{", b"\x00" * 64, b"[]", b"\xff\xfe\xfa"])
+def test_broken_config_json_starts_afresh(make_hm, hm_dirs, contents, callback):
+ cfg_file = hm_dirs['tools_dir'] / "PortMaster" / "config" / "config.json"
+ make_hm()
+ cfg_file.write_bytes(contents)
+
+ hm = make_hm()
+
+ assert hm.cfg_data['theme'] == "default_theme"
+ assert json.loads(cfg_file.read_text())['first-run'] is False
+
+
+@pytest.mark.parametrize("contents, expected", [
+ (None, {}),
+ (b"", {}),
+ (b"{", {}),
+ (b"[1, 2]", {}),
+ (b"\xff\xfe", {}),
+ (b'{"a": 1}', {"a": 1}),
+ ])
+def test_json_load_dict(tmp_path, contents, expected):
+ path = tmp_path / "x.json"
+ if contents is not None:
+ path.write_bytes(contents)
+
+ assert util.json_load_dict(path) == expected
+ assert util.json_load_dict(path, {"d": 1}) == (expected or {"d": 1})
+
+
+def test_json_load_dict_default_is_copied(tmp_path):
+ default = {"a": 1}
+ result = util.json_load_dict(tmp_path / "missing.json", default)
+ result["a"] = 2
+
+ assert default == {"a": 1}
+
+
+def test_gui_get_config_broken(gui, monkeypatch, tmp_path):
+ tools = tmp_path / "guitools"
+ (tools / "PortMaster" / "config").mkdir(parents=True)
+ (tools / "PortMaster" / "config" / "config.json").write_text("{")
+ monkeypatch.setattr(harbourmaster, "HM_TOOLS_DIR", tools)
+ monkeypatch.setattr(gui, "hm", None)
+
+ assert gui.get_config() == {}
+
+
+def test_broken_themes_json(pugwash, hm):
+ ## A themes.json missing keys took the theme list down with a KeyError.
+ from types import SimpleNamespace
+ from pugwash.theme import ThemeDownloader
+
+ (hm.cfg_dir / "themes.json").write_text('{"name": "partial"}')
+ downloader = ThemeDownloader(SimpleNamespace(hm=hm), None)
+
+ assert downloader._config['prefix'] == "theme"
+ assert downloader._config['name'] == "partial"
+
+
+@pytest.mark.parametrize("contents", ["", "{", "[]"])
+def test_unreadable_themes_json(pugwash, hm, contents):
+ from types import SimpleNamespace
+ from pugwash.theme import ThemeDownloader
+
+ (hm.cfg_dir / "themes.json").write_text(contents)
+ downloader = ThemeDownloader(SimpleNamespace(hm=hm), None)
+
+ assert downloader._config['name'] == "PortMaster Themes"
diff --git a/tests/test_util_edges.py b/tests/test_util_edges.py
new file mode 100644
index 00000000..45afd938
--- /dev/null
+++ b/tests/test_util_edges.py
@@ -0,0 +1,569 @@
+# SPDX-License-Identifier: MIT
+#
+# Edge cases of harbourmaster.util and harbourmaster.info, and uninstalling safely
+# whatever an installed port.json says.
+
+import datetime
+import hashlib
+import json
+
+import pytest
+
+import harbourmaster
+from harbourmaster import info as hm_info_module
+from harbourmaster import util
+from harbourmaster.util import files as util_files
+from harbourmaster.util import net as util_net
+from harbourmaster.util import text as util_text
+
+from conftest import RecordingCallback, basic_port_files, write_port_zip
+
+
+################################################################################
+## Text
+@pytest.mark.parametrize("size, expected", [
+ (0, "0 B"),
+ (767, "767 B"),
+ (768, "0.75 KB"),
+ (1024 * 1024, "1.00 MB"),
+ (5 * 1024 ** 4, "5.00 TB"),
+ (1024 ** 6, "1048576.00 TB"),
+ ])
+def test_nice_size_edges(size, expected):
+ assert util.nice_size(size) == expected
+
+
+@pytest.mark.parametrize("version, expected", [
+ ("", ()),
+ ("2.30", (2, 30)),
+ ("v1.2", ("v", 1, 2)),
+ ("1.0beta2", (1, 0, "beta", 2)),
+ ("2026.10.18-0059", (2026, 10, 18, 59)),
+ ("(1)[2],3_4", (1, 2, 3, 4)),
+ ("abc", ("abc",)),
+ ])
+def test_version_parse_edges(version, expected):
+ assert util.version_parse(version) == expected
+
+
+def test_version_parse_orders_releases():
+ versions = ["2026.10.18-0059", "2026.06.23-1000", "2026.10.18-0100", "2025.12.31-2359"]
+ assert sorted(versions, key=util.version_parse) == ["2025.12.31-2359", "2026.06.23-1000", "2026.10.18-0059", "2026.10.18-0100"]
+
+
+def test_list_to_tuple():
+ @util.list_to_tuple
+ def echo(*args):
+ return list(args)
+
+ assert echo([1, 2], "x") == ((1, 2), "x")
+
+
+@pytest.mark.parametrize("text, expected", [
+ ("Half-Life.zip", "half-life.zip"),
+ (" Cave Story+.ZIP ", "cave.story.zip"),
+ ("a b..c", "a.b.c"),
+ ("日本語.zip", ".zip"),
+ ("", ""),
+ ])
+def test_name_cleaner_edges(text, expected):
+ assert util.name_cleaner(text) == expected
+
+
+@pytest.mark.parametrize("runtime, expected", [
+ ("frt_3.5.2.squashfs", "Godot/FRT 3.5.2"),
+ ("solarus-1.6.5.squashfs", "Solarus 1.6.5"),
+ ("mono-6.12.0.122-aarch64.squashfs", "Mono 6.12.0.122"),
+ ("rlvm.squashfs", "RLVM"),
+ ("pyxel_2.2.8_python_3.11.squashfs", "Pyxel 2.2.8"),
+ ("weston_pkg_0.2.squashfs", "Weston 0.2.squashfs"),
+ ("mesa_pkg_0.1.squashfs", "Mesa 0.1.squashfs"),
+ ("zulu11.48.21-ca-jdk11.0.11-linux_aarch64.squashfs", "JDK 11.0.11"),
+ ("zulu11.48.21-ca-jre11.0.11-linux_aarch64.squashfs", "JRE 11.0.11"),
+ ("something.squashfs", "something.squashfs"),
+ ## Not named the usual way: shown as they are rather than crashing.
+ ("frt.squashfs", "frt.squashfs"),
+ ("solarus.squashfs", "solarus.squashfs"),
+ ("mono.squashfs", "mono.squashfs"),
+ ("pyxel.squashfs", "pyxel.squashfs"),
+ ("zulu11-jdk.squashfs", "zulu11-jdk.squashfs"),
+ (["rlvm.squashfs", "mono.squashfs"], "RLVM and mono.squashfs"),
+ ([], ""),
+ ])
+def test_runtime_nicename_edges(runtime, expected):
+ assert util.runtime_nicename(runtime) == expected
+
+
+def test_datetime_compare():
+ now = datetime.datetime.now()
+ hour_ago = (now - datetime.timedelta(hours=1)).isoformat()
+
+ assert 3590 < util.datetime_compare(hour_ago) < 3700
+ assert util.datetime_compare("2020-01-01T00:00:00", "2020-01-01T00:01:00") == 60
+ assert util.datetime_compare(datetime.datetime(2020, 1, 2), datetime.datetime(2020, 1, 1)) == -86400
+
+
+@pytest.mark.parametrize("value", ["garbage", "", "2026-13-45", 5, None, ["2020-01-01"]])
+def test_datetime_compare_unreadable_is_long_ago(value):
+ ## A broken last_checked crashed loading the sources.
+ assert util.datetime_compare(value) == float('inf')
+ assert util.datetime_compare(value) > 10 ** 9
+
+
+def test_datetime_compare_with_a_timezone():
+ ## Aware minus naive raised TypeError.
+ now = datetime.datetime.now(datetime.timezone.utc).isoformat()
+ assert abs(util.datetime_compare(now)) < 60
+
+
+def test_bad_last_checked_updates_the_source(make_hm, hm_dirs, monkeypatch):
+ from harbourmaster import hm_info
+ fetched = []
+ monkeypatch.setattr(util_net, "fetch_json", lambda url: fetched.append(url))
+ monkeypatch.setattr(hm_info, "fetch_text", lambda url: '{"tree": []}')
+
+ source_file = hm_dirs['tools_dir'] / "PortMaster" / "config" / "020_portmaster.source.json"
+ make_hm()
+ data = json.loads(source_file.read_text())
+ data['last_checked'] = "yesterday"
+ data['version'] = 2
+ source_file.write_text(json.dumps(data))
+
+ make_hm(**{'offline': False, 'no-check': False})
+ assert data['url'] in fetched
+
+
+def test_timeit(monkeypatch):
+ monkeypatch.setattr(util_text, "HM_PERFTEST", True)
+
+ @util_text.timeit
+ def add(a, b=1):
+ return a + b
+
+ assert add(1, b=2) == 3
+ assert add.__name__ == "add"
+
+
+def test_timeit_off_returns_the_function():
+ def f():
+ pass
+
+ assert util_text.timeit(f) is f
+
+
+################################################################################
+## Files
+def test_signature_path_types(tmp_path):
+ script = tmp_path / "x.sh"
+ script.write_text("#!/bin/bash\necho\n")
+
+ util.add_pm_signature(str(script), ["x.zip", "x.sh"])
+ assert util.load_pm_signature(str(script)) == ["x.zip", "x.sh"]
+
+ for func in (util.load_pm_signature, util.remove_pm_signature, util.hash_file, util.hash_file_sha, util.calculate_git_blob_sha_file):
+ with pytest.raises(ValueError):
+ func(5)
+
+ with pytest.raises(ValueError):
+ util.add_pm_signature(5, ["a", "b"])
+
+ with pytest.raises(ValueError):
+ util.calculate_git_blob_sha_data(5)
+
+
+def test_signature_replaced_not_duplicated(tmp_path):
+ script = tmp_path / "x.sh"
+ script.write_text("#!/bin/bash\n# PORTMASTER: old.zip, x.sh\necho\n")
+
+ util.add_pm_signature(script, ["new.zip", "x.sh"])
+
+ assert script.read_text().count("PORTMASTER:") == 1
+ assert util.load_pm_signature(script) == ["new.zip", "x.sh"]
+
+
+def test_signature_unchanged_isnt_rewritten(tmp_path):
+ script = tmp_path / "x.sh"
+ script.write_text("#!/bin/bash\n# PORTMASTER: x.zip, x.sh\necho\n")
+ before = script.stat().st_mtime_ns
+
+ util.add_pm_signature(script, ["x.zip", "x.sh"])
+ assert script.stat().st_mtime_ns == before
+
+
+def test_signature_needs_a_comma(tmp_path):
+ script = tmp_path / "x.sh"
+ script.write_text("#!/bin/bash\n# PORTMASTER: nothing here\n")
+ assert util.load_pm_signature(script) is None
+
+
+def test_signature_missing_and_non_scripts(tmp_path):
+ assert util.load_pm_signature(tmp_path / "missing.sh") is None
+ util.add_pm_signature(tmp_path / "missing.sh", ["a", "b"])
+ util.remove_pm_signature(tmp_path / "missing.sh")
+ assert not (tmp_path / "missing.sh").exists()
+
+ text = tmp_path / "x.txt"
+ text.write_text("# PORTMASTER: a, b\n")
+ util.remove_pm_signature(text)
+ assert text.read_text() == "# PORTMASTER: a, b\n"
+
+
+def test_remove_signature(tmp_path):
+ script = tmp_path / "x.sh"
+ script.write_text("#!/bin/bash\n# PORTMASTER: x.zip, x.sh\necho\n")
+
+ util.remove_pm_signature(str(script))
+ assert script.read_text() == "#!/bin/bash\necho\n"
+
+ ## Nothing to remove.
+ util.remove_pm_signature(script)
+ assert script.read_text() == "#!/bin/bash\necho\n"
+
+
+def test_signature_unreadable_script(tmp_path, log_errors, monkeypatch):
+ script = tmp_path / "x.sh"
+ script.write_text("#!/bin/bash\n")
+
+ def fail(*args, **kwargs):
+ raise PermissionError("denied")
+
+ monkeypatch.setattr(util_files, "open", fail, raising=False)
+ assert util.load_pm_signature(script) is None
+ assert any("denied" in message for message in log_errors)
+
+
+def test_signature_non_utf8_script(tmp_path, log_errors):
+ script = tmp_path / "x.sh"
+ script.write_bytes(b"#!/bin/bash\necho \xff\xfe\n")
+
+ util.add_pm_signature(script, ["x.zip", "x.sh"])
+ ## Left alone rather than crashing.
+ assert script.read_bytes() == b"#!/bin/bash\necho \xff\xfe\n"
+
+
+def test_hashes(tmp_path):
+ path = tmp_path / "x"
+ path.write_bytes(b"abc")
+
+ assert util.hash_file(str(path)) == hashlib.md5(b"abc").hexdigest()
+ assert util.hash_file_sha(path) == hashlib.sha1(b"abc").hexdigest()
+ assert util.hash_file_sha(str(path)) == hashlib.sha1(b"abc").hexdigest()
+ assert util.hash_file_sha(tmp_path / "missing") is None
+ assert util.hash_file(tmp_path) is None
+ assert util.calculate_git_blob_sha_data("abc") == util.calculate_git_blob_sha_data(b"abc")
+ assert util.calculate_git_blob_sha_file(str(path)) == util.calculate_git_blob_sha_data(b"abc")
+
+
+def test_make_temp_directory():
+ with util.make_temp_directory() as temp_dir:
+ (temp_dir / "x").write_text("x")
+ assert temp_dir.is_dir()
+
+ assert not temp_dir.exists()
+
+
+def test_make_temp_directory_cleans_up_after_errors():
+ with pytest.raises(RuntimeError):
+ with util.make_temp_directory() as temp_dir:
+ raise RuntimeError()
+
+ assert not temp_dir.exists()
+
+
+def test_get_path_fs_testing():
+ ## Never shells out from a checkout.
+ assert util.get_path_fs("/") is None
+
+
+@pytest.mark.parametrize("output, expected", [
+ (b"Filesystem Type 1024-blocks Used Available Capacity Mounted on\n/dev/mmcblk0p1 exfat 100 50 50 50% /mnt/sdcard\n", "exfat"),
+ (b"Filesystem Type\n", None),
+ (b"Filesystem Type\n\n", None),
+ (b"Filesystem Type\nonlyone\n", None),
+ ])
+def test_get_path_fs(tmp_path, monkeypatch, output, expected):
+ monkeypatch.setattr(util_files, "HM_TESTING", False)
+ monkeypatch.setattr(util_files.subprocess, "check_output", lambda *args, **kwargs: output)
+
+ assert util.get_path_fs(tmp_path) == expected
+ assert util.get_path_fs(str(tmp_path)) == expected
+
+
+def test_get_path_fs_missing_and_errors(tmp_path, monkeypatch):
+ monkeypatch.setattr(util_files, "HM_TESTING", False)
+ assert util.get_path_fs(tmp_path / "missing") is None
+ assert util.get_path_fs(str(tmp_path / "missing")) is None
+ assert util.get_path_fs(5) is None
+
+ def fail(*args, **kwargs):
+ raise util_files.subprocess.CalledProcessError(1, "df")
+
+ monkeypatch.setattr(util_files.subprocess, "check_output", fail)
+ assert util.get_path_fs(tmp_path) is None
+
+
+################################################################################
+## Network
+class Response:
+ def __init__(self, status_code=200, content=b"", text=None, json_data=None, headers=None, chunks=None):
+ self.status_code = status_code
+ self.content = content
+ self.text = text
+ self._json = json_data
+ self.headers = headers or {}
+ self.encoding = None
+ self.chunks = chunks if chunks is not None else [content]
+
+ def json(self):
+ return self._json
+
+ def iter_content(self, chunk_size=None, decode_unicode=False):
+ yield from self.chunks
+
+
+def serve(monkeypatch, response):
+ monkeypatch.setattr(util_net.requests, "get", lambda *args, **kwargs: response)
+
+
+def test_fetch_status_error(monkeypatch, log_errors):
+ serve(monkeypatch, Response(status_code=404))
+
+ assert util.fetch_json("https://example.com/x") is None
+ assert util.fetch_data("https://example.com/x") is None
+ assert util.fetch_text("https://example.com/x") is None
+ assert any("404" in message for message in log_errors)
+
+
+def test_fetch_ok(monkeypatch):
+ response = Response(content=b"data", text="tëxt", json_data={"a": 1})
+ serve(monkeypatch, response)
+
+ assert util.fetch_data("u") == b"data"
+ assert util.fetch_json("u") == {"a": 1}
+ assert util.fetch_text("u") == "tëxt"
+ assert response.encoding == "utf-8"
+
+
+def test_download_without_content_length(tmp_path, monkeypatch, callback):
+ serve(monkeypatch, Response(content=b"abc"))
+
+ result = util.download(tmp_path / "x", "u", hashlib.md5(b"abc").hexdigest(), callback=callback)
+
+ assert result == tmp_path / "x"
+ assert any("???? MB" in message for message in callback.messages)
+ assert callback.progress_calls[-2][2] is None
+
+
+def test_download_without_callback(tmp_path, monkeypatch, capsys):
+ serve(monkeypatch, Response(content=b"abc", headers={'content-length': "3"}))
+
+ md5_result = [None]
+ assert util.download(tmp_path / "x", "u", md5_result=md5_result) == tmp_path / "x"
+ assert md5_result == [hashlib.md5(b"abc").hexdigest()]
+
+ out = capsys.readouterr().out
+ assert "Downloading" in out and "3 B / 3 B" in out
+
+
+def test_download_without_callback_unknown_length(tmp_path, monkeypatch, capsys):
+ serve(monkeypatch, Response(content=b"abc"))
+
+ util.download(tmp_path / "x", "u", hashlib.md5(b"abc").hexdigest())
+ out = capsys.readouterr().out
+ assert "?" * 40 in out and "Passed md5 check." in out
+
+
+def test_download_http_error_without_callback(tmp_path, monkeypatch):
+ serve(monkeypatch, Response(status_code=500))
+ assert util.download(tmp_path / "x", "u") is None
+
+
+def test_download_no_md5(tmp_path, monkeypatch, callback, log_errors):
+ serve(monkeypatch, Response(content=b"abc", headers={'content-length': "3"}))
+
+ assert util.download(tmp_path / "x", "u", callback=callback) == tmp_path / "x"
+ assert "Unable to validate download." in callback.messages
+
+
+def test_download_md5_mismatch_without_callback(tmp_path, monkeypatch):
+ serve(monkeypatch, Response(content=b"abc", headers={'content-length': "3"}))
+
+ assert util.download(tmp_path / "x", "u", "0" * 32) is None
+ assert not (tmp_path / "x").exists()
+
+
+################################################################################
+## port_info_load
+@pytest.mark.parametrize("item", ["..", "a/..", "../x", "a/../b", "/etc", "", ".", "./", "\\x", "x\\..\\y", 5, None, ["a"]])
+def test_load_drops_unsafe_items(item, log_errors):
+ info = harbourmaster.port_info_load({"name": "x.zip", "items": ["Good.sh", item, "good/"], "items_opt": [item, "opt/"]})
+
+ assert info['items'] == ["Good.sh", "good/"]
+ assert info['items_opt'] == ["opt/"]
+
+
+def test_load_items_opt_only_unsafe_is_none():
+ info = harbourmaster.port_info_load({"name": "x.zip", "items": ["a.sh"], "items_opt": [".."]})
+ assert info['items_opt'] is None
+
+
+@pytest.mark.parametrize("items_opt", ["opt/", 5, {"a": 1}])
+def test_load_items_opt_not_a_list(items_opt):
+ info = harbourmaster.port_info_load({"name": "x.zip", "items": ["a.sh"], "items_opt": items_opt})
+ assert info['items_opt'] is None
+
+
+@pytest.mark.parametrize("item, safe", [
+ ("Port.sh", True),
+ ("port/", True),
+ ("a..b/", True),
+ ("..hidden", True),
+ ("..", False),
+ ("x/../y", False),
+ ])
+def test_is_safe_port_item(item, safe):
+ assert hm_info_module.is_safe_port_item(item) is safe
+
+
+@pytest.mark.parametrize("raw", [5, None, 1.5, ["a"]])
+def test_load_not_port_info(raw, log_errors):
+ assert harbourmaster.port_info_load(raw) is None
+ assert harbourmaster.port_info_load(raw, do_default=True)['attr']['title'] == ""
+
+
+def test_load_bad_strings(tmp_path):
+ assert harbourmaster.port_info_load("{not json}") is None
+ assert harbourmaster.port_info_load("{not json}", do_default=True)['name'] is None
+ assert harbourmaster.port_info_load('{"version": "x"}') is None
+ assert harbourmaster.port_info_load('{"attr": []}') is None
+ assert harbourmaster.port_info_load("no such file") is None
+
+ path = tmp_path / "port.json"
+ path.write_text("[1]")
+ assert harbourmaster.port_info_load(str(path)) is None
+ assert harbourmaster.port_info_load(path) is None
+ assert harbourmaster.port_info_load(str(path), do_default=True)['items'] is None
+
+
+def test_load_bad_version_with_default():
+ assert harbourmaster.port_info_load({"version": 99}, do_default=True)['version'] == 4
+ assert harbourmaster.port_info_load({"version": [1]}, do_default=True)['attr']['genres'] == []
+
+
+def test_load_attr_type_cleanup():
+ info = harbourmaster.port_info_load({"name": "x.zip", "attr": {
+ "genres": "Puzzle", "porter": None, "runtime": "mono.squashfs", "reqs": ["a", 5], "arch": 5,
+ "title": 5, "desc": None, "image": "shot.png", "rtr": "yes", "exp": 0,
+ }})
+ attr = info['attr']
+
+ assert attr['genres'] == ["puzzle"]
+ assert attr['porter'] == []
+ assert attr['runtime'] == ["mono.squashfs"]
+ assert attr['reqs'] == ["a"]
+ assert attr['arch'] == []
+ assert attr['title'] == "5"
+ assert attr['desc'] == ""
+ assert attr['image'] == {"screenshot": "shot.png"}
+ assert attr['rtr'] is True and attr['exp'] is False
+
+ assert harbourmaster.port_info_load({"attr": {"image": 5}})['attr']['image'] == {}
+
+
+def test_load_v1_without_md5():
+ info = harbourmaster.port_info_load({"version": 1, "source": "https://x/y/port.zip", "attr": {"runtime": None}})
+ assert info['name'] == "port.zip"
+ assert 'status' not in info
+ assert info['attr']['runtime'] == []
+
+
+def test_load_v3_runtime_string():
+ info = harbourmaster.port_info_load({"version": 3, "attr": {"runtime": "x.squashfs"}})
+ assert info['attr']['runtime'] == ["x.squashfs"]
+
+
+def test_merge_bad_other(log_errors):
+ info = harbourmaster.port_info_load({})
+ assert harbourmaster.port_info_merge(info, 5) is None
+
+
+def test_merge_from_a_file(tmp_path):
+ path = tmp_path / "port.json"
+ path.write_text(json.dumps({"name": "x.zip", "attr": {"title": "From file", "rtr": True}, "status": {"md5": "a"}}))
+ info = harbourmaster.port_info_load({})
+
+ harbourmaster.port_info_merge(info, path)
+
+ assert info['name'] == "x.zip"
+ assert info['attr']['title'] == "From file"
+ assert info['attr']['rtr'] is True
+ assert info['status'] == {"md5": "a"}
+
+
+def test_merge_keeps_what_is_set():
+ info = harbourmaster.port_info_load({"name": "a.zip", "items": ["a.sh"], "attr": {"title": "A", "genres": ["rpg"]}, "files": {"x": "y"}})
+ other = harbourmaster.port_info_load({"name": "b.zip", "items": ["b.sh"], "attr": {"title": "B", "genres": ["puzzle"], "desc": "From b"}, "files": {"z": "z"}})
+
+ harbourmaster.port_info_merge(info, other)
+
+ assert info['name'] == "a.zip"
+ assert info['items'] == ["a.sh"]
+ assert info['attr']['title'] == "A"
+ assert info['attr']['genres'] == ["rpg"]
+ assert info['attr']['desc'] == "From b"
+ assert info['files'] == {"x": "y"}
+
+
+################################################################################
+## Uninstalling whatever port.json says
+@pytest.fixture
+def sandbox(tmp_path):
+ """The ports folder inside a folder holding something precious."""
+ root = tmp_path / "sdcard"
+ dirs = {
+ 'tools_dir': root / "tools",
+ 'ports_dir': root / "ports",
+ 'scripts_dir': root / "ports",
+ 'temp_dir': tmp_path / "temp",
+ }
+ for value in dirs.values():
+ value.mkdir(parents=True, exist_ok=True)
+
+ (root / "precious.txt").write_text("keep me")
+ hm = harbourmaster.HarbourMaster({'offline': True, 'no-check': True}, callback=RecordingCallback(), **dirs)
+
+ zip_file = write_port_zip(tmp_path / "testport.zip", basic_port_files())
+ assert hm.install_port(str(zip_file)) == 0
+ hm.root = root
+ hm.port_json = dirs['ports_dir'] / "testport" / "port.json"
+ return hm
+
+
+def edit_port_json(hm, change):
+ data = json.loads(hm.port_json.read_text())
+ change(data)
+ hm.port_json.write_text(json.dumps(data))
+ hm.load_ports()
+
+
+## Never a real system path here: the test suite also runs on devices, as root. Absolute
+## paths point into the sandbox ("{root}").
+@pytest.mark.parametrize("item", ["..", "../", "testport/../..", "{root}"])
+def test_uninstall_ignores_items_outside_the_ports_folder(sandbox, item):
+ ## `..` in an installed port.json's items deleted the folder holding the ports folder.
+ item = item.format(root=sandbox.root)
+ edit_port_json(sandbox, lambda data: data['items'].append(item))
+
+ assert sandbox.uninstall_port("testport.zip") == 0
+ assert (sandbox.root / "precious.txt").read_text() == "keep me"
+ assert not (sandbox.ports_dir / "testport").exists()
+
+
+@pytest.mark.parametrize("value", ["../precious.txt", "..", "{root}/precious.txt", "{root}", "testport/../../precious.txt"])
+def test_uninstall_ignores_files_outside_the_ports_folder(sandbox, value, log_errors):
+ value = value.format(root=sandbox.root)
+ edit_port_json(sandbox, lambda data: data['files'].__setitem__("extra", value))
+
+ assert sandbox.uninstall_port("testport.zip") == 0
+ assert (sandbox.root / "precious.txt").read_text() == "keep me"
+ assert not (sandbox.ports_dir / "Test Port.sh").exists()