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()