From ea2b2ce6aa4dca9f88f2ece6994a1f09b73a72e0 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 15:16:32 +0800 Subject: [PATCH 01/13] Sources: a failed or repeated update keeps the ports already loaded - PortMasterV3.update() emptied the port list before checking whether it had already updated this run, so "Update ports" right after the startup update showed nothing until PortMaster restarted. - When ports.json can't be fetched, the cached ports are loaded instead of leaving the list empty for the session. - net.fetch() caught only ConnectionError: a read timeout (a slow first-boot network) went up through HarbourMaster's startup. It now catches any requests error, and fetch_json() returns None for a body that isn't JSON (a captive portal's page). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- .../pylibs/harbourmaster/source/portmaster.py | 20 ++++--- PortMaster/pylibs/harbourmaster/util/net.py | 11 +++- tests/test_source.py | 53 +++++++++++++++++++ 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/PortMaster/pylibs/harbourmaster/source/portmaster.py b/PortMaster/pylibs/harbourmaster/source/portmaster.py index 3ff1e8d7..e719d7aa 100644 --- a/PortMaster/pylibs/harbourmaster/source/portmaster.py +++ b/PortMaster/pylibs/harbourmaster/source/portmaster.py @@ -41,14 +41,8 @@ def update(self): if self.hm.callback is not None: self.hm.callback.message(" - {}".format(_("Updating"))) - changed = False - # Scrap the rest - self._data = {} - self._info = {} - self.ports = [] - self.utils = [] - self.images = {} - + ## Already fetched this run: keep what's loaded. Clearing it first emptied the + ## port list on "Update ports" right after the startup update. if self._did_update: self.hm.callback.message(" - {}".format(_("Up to date already"))) return @@ -58,8 +52,18 @@ def update(self): 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 = {} + self._info = {} + self.ports = [] + self.utils = [] + self.images = {} + ## Load data from the assets. for key, asset in data['ports'].items(): asset = port_info_load(asset) 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/tests/test_source.py b/tests/test_source.py index d6c19410..cab9a813 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,57 @@ def test_update_failure_keeps_going(hm, monkeypatch): assert source.ports == [] +def test_update_again_keeps_the_ports(pm_source, ports_json_data): + ## "Update ports" after the startup update used to empty the list until a restart. + pm_source.update() + + assert pm_source.fetched == [pm_source._config['url']] + assert sorted(pm_source.ports) == sorted(ports_json_data['ports']) + assert pm_source.port_info("sprout.zip")['attr']['title'] + + +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) From 90715d1ed6f4e0cb1c554b1e977ef7774a196ec7 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 16:29:25 +0800 Subject: [PATCH 02/13] Tests for the CLI, PortMaster-Info and installed ports, and the bugs they found New tests: test_cli_commands.py (every command in-process, ConsoleCallback, fifo_control, main's flags), test_hm_info.py (load_info against a fake PortMaster-Info tree) and test_hm_ports.py (load_ports' repairs, renamed scripts, broken/unknown ports, attributes, requirements, listing, sorting, images). Fixed: - `harbourmaster ports.json` crashed on any port that isn't installed (KeyError: 'status'). - `harbourmaster help device_info` crashed: it had no docstring. - A corrupt runtimes.json crashed every start. - A PortMaster-Info file over 1MB that failed validation was deleted and then copied over the good one: FileNotFoundError. - A port.json without a name took the first letter of its port's name from ports_info.json (whose items are mostly strings), and missed scripts whose names aren't lower case. - An unsigned renamed script found by its md5 got its names swapped, so its port showed as broken. - A signed renamed script of a port without a port.json was also listed as an unknown port. - A port.json made for such a port left out its own 'port.json' entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/harbourmaster/cli.py | 10 +- PortMaster/pylibs/harbourmaster/hm_info.py | 8 +- PortMaster/pylibs/harbourmaster/hm_ports.py | 19 +- tests/test_cli_commands.py | 465 ++++++++++++ tests/test_hm_info.py | 355 +++++++++ tests/test_hm_ports.py | 774 ++++++++++++++++++++ 6 files changed, 1622 insertions(+), 9 deletions(-) create mode 100644 tests/test_cli_commands.py create mode 100644 tests/test_hm_info.py create mode 100644 tests/test_hm_ports.py diff --git a/PortMaster/pylibs/harbourmaster/cli.py b/PortMaster/pylibs/harbourmaster/cli.py index 4c028ee7..fc3979dd 100644 --- a/PortMaster/pylibs/harbourmaster/cli.py +++ b/PortMaster/pylibs/harbourmaster/cli.py @@ -271,8 +271,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 +469,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 = {} 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_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/tests/test_cli_commands.py b/tests/test_cli_commands.py new file mode 100644 index 00000000..f2564174 --- /dev/null +++ b/tests/test_cli_commands.py @@ -0,0 +1,465 @@ +# 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() + + import time + end = time.monotonic() + 10 + while not (fifo.exists() and not fifo.is_file()) and time.monotonic() < end: + 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] 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 From 022bb4b29f5344766ebf2898008f9438dc8f31a9 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 16:39:12 +0800 Subject: [PATCH 03/13] Tests for platform hooks, runtimes, featured ports and the update check, and the bugs they found New tests: test_platform_hooks.py (every CFW's hooks against a fake device root), test_hm_runtimes.py (list_runtimes, check_runtime, featured ports) and test_update_check.py (the self-update check, broken config files). Fixed: - A broken config.json (cut off by a power off while saving) stopped PortMaster starting: HarbourMaster, the update check and the GUI now start from the defaults. themes.json likewise, and one missing keys no longer takes the theme list down. New harbourmaster.json_load_dict() for these; json_safe_load() also survives a file that isn't text. - A version.json without the channel's version/url/md5 crashed the update check. - muOS/TrimUI catalogue errors failed the port install: the "a broken gamelist never fails an install" guard now covers them too. - muOS: a catalogue without its box/preview/text folders failed every install with a gameinfo.xml; they're made inside an existing catalogue. - RetroDECK without a retrodeck.json or .cfg failed every such install too. - list_runtimes() saved config.json on every call, and never noticed a runtime copied in after it was listed as Not Installed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/harbourmaster/__init__.py | 1 + PortMaster/pylibs/harbourmaster/harbour.py | 9 +- .../pylibs/harbourmaster/hm_runtimes.py | 8 +- .../pylibs/harbourmaster/platform/base.py | 16 +- .../pylibs/harbourmaster/platform/muos.py | 7 + .../harbourmaster/platform/retrodeck.py | 4 + .../pylibs/harbourmaster/util/__init__.py | 2 + PortMaster/pylibs/harbourmaster/util/data.py | 26 + PortMaster/pylibs/pugwash/app.py | 3 +- PortMaster/pylibs/pugwash/theme.py | 11 +- PortMaster/pylibs/pugwash/update.py | 6 +- tests/test_hm_runtimes.py | 336 +++++++ tests/test_platform_hooks.py | 844 ++++++++++++++++++ tests/test_update_check.py | 284 ++++++ 14 files changed, 1536 insertions(+), 21 deletions(-) create mode 100644 tests/test_hm_runtimes.py create mode 100644 tests/test_platform_hooks.py create mode 100644 tests/test_update_check.py 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/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_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/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/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/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/theme.py b/PortMaster/pylibs/pugwash/theme.py index 4c8aff52..acc1dc22 100644 --- a/PortMaster/pylibs/pugwash/theme.py +++ b/PortMaster/pylibs/pugwash/theme.py @@ -417,12 +417,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 +563,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 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_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_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_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" From 1b0dd7770e2d683c781882f6273877d19bfa5f75 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 16:46:40 +0800 Subject: [PATCH 04/13] Uninstall never deletes outside the ports folder; tests for util, info, config, hardware Uninstalling deletes each name in the port's port.json, and a `..` in its items (or a `../x` or absolute path in its files) deleted outside the ports folder: `..` took the folder holding it, on muOS the SD card. Install was safe (it takes the items from the zip), but a hand-edited or broken port.json on the device wasn't. port_info_load() now drops any item that isn't a plain relative name (new info.is_safe_port_item()), and uninstall skips anything else whatever port.json says. Also fixed: - runtime_nicename() crashed on runtimes not named the usual way ("mono.squashfs" in a hand-written port.json). - datetime_compare() crashed on a broken or timezone-aware time (a source's last_checked): it now counts as long ago, so the check runs. - cprint() crashed when stdout isn't a real file (in_terminal()). - nice_size() divided once too often past TB. - items_opt that isn't a list, and non-string items, crashed port_info_load(). New tests: test_util_edges.py (text, files, net, port_info_load/merge, safe uninstall in a sandbox), test_hardware_edges.py (the quick CFW/device probe against a fake root, get_info details) and more detect_paths cases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/harbourmaster/console.py | 7 +- PortMaster/pylibs/harbourmaster/hm_install.py | 8 +- PortMaster/pylibs/harbourmaster/info.py | 82 +-- PortMaster/pylibs/harbourmaster/util/text.py | 41 +- tests/test_config.py | 53 ++ tests/test_hardware_edges.py | 271 +++++++++ tests/test_util_edges.py | 569 ++++++++++++++++++ 7 files changed, 969 insertions(+), 62 deletions(-) create mode 100644 tests/test_hardware_edges.py create mode 100644 tests/test_util_edges.py 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/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/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/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/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_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_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() From 387f7cb2f5544dcb44cb78dce20147252f002246 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 16:51:57 +0800 Subject: [PATCH 05/13] Tests driving the GUI's menus a button at a time test_gui_scenes.py: the main menu (every entry, exit, the Konami code), ports lists (moving, selecting, empty lists, no HarbourMaster), filters (genres, attributes, porters, sorting and reversing, clearing), featured ports (categories, lists), every option (toggles, release channels, restore, GCD modes, gamelist, TrimUI's port location, the secret options), the language list and the on screen keyboard. Also: - Options' "Delete PortMaster Config" crashed when the config folder was already gone; it and "Delete PortMaster Runtimes" use the HarbourMaster's folders (the same ones on a device). - LanguageScene: A on a blank row can't crash (the d-pad skips them, a theme's list might not). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/pugwash/scenes/language.py | 7 +- PortMaster/pylibs/pugwash/scenes/options.py | 5 +- tests/test_gui_scenes.py | 739 +++++++++++++++++++ 3 files changed, 748 insertions(+), 3 deletions(-) create mode 100644 tests/test_gui_scenes.py diff --git a/PortMaster/pylibs/pugwash/scenes/language.py b/PortMaster/pylibs/pugwash/scenes/language.py index 6dc678ad..46a6b3cd 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() 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/tests/test_gui_scenes.py b/tests/test_gui_scenes.py new file mode 100644 index 00000000..aadfbec5 --- /dev/null +++ b/tests/test_gui_scenes.py @@ -0,0 +1,739 @@ +# 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', 'theme-editor', '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 test_language_choose(scene_gui, language, monkeypatch): + reloaded = [] + monkeypatch.setattr(scene_gui, "reload_language", lambda code, then=None: reloaded.append((code, [name for name, _ in then]))) + + region = language.tags['option_list'] + others = [option for option in region.options if isinstance(option, tuple) and option[0] == 'select-language'] + assert others + + select(language, others[0]) + scene_gui.do_loop(no_delay=True) + press(scene_gui, monkeypatch, 'A') + + code = others[0][1] + assert reloaded == [(code, ['option', 'select-language'])] + from pugwash import lang + if code == lang.DEFAULT_LANG: + assert 'language' not in scene_gui.hm.cfg_data + else: + assert scene_gui.hm.cfg_data['language'] == code + + +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 From 5a5d0a3a3df7a7eeef6cb8322938b54b83c502f9 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 16:56:58 +0800 Subject: [PATCH 06/13] Tests for the startup, port info, runtime manager and theme screens test_gui_screens.py: the startup warnings for unsupported firmware and low RAM (against a fake device root), the disclaimer and warning screens (waiting, accepting, quitting), which first screen TempMenuScene picks, port info (install, uninstall, next/previous port, the popup and its links), the runtime manager (details, check, download all, uninstall, ports using a runtime) and the theme and colour scheme lists. ThemeSchemeScene: A on a blank row can't crash, as in LanguageScene. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/pugwash/scenes/themes.py | 7 +- tests/test_gui_screens.py | 584 +++++++++++++++++++++ 2 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 tests/test_gui_screens.py 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/tests/test_gui_screens.py b/tests/test_gui_screens.py new file mode 100644 index 00000000..67e38385 --- /dev/null +++ b/tests/test_gui_screens.py @@ -0,0 +1,584 @@ +# 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_links(popup_theme, monkeypatch): + screen_gui = popup_theme + popup_info(screen_gui, "sprout.zip") + + press(screen_gui, monkeypatch, 'UP') + popup = top(screen_gui) + assert "Links" in bar(popup) + + press(screen_gui, monkeypatch, 'X') + assert screen_gui.scene_list()[-1] == "port_links" + + +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_links(screen_gui, info, monkeypatch): + ## 2048plus has no stores, and the website link is only for the official sources' ports. + screen_gui.set_data("port_info.links", "") + assert not info.has_links() + assert info.show_links() is False + + +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'] + 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): + reloads = [] + monkeypatch.setattr(screen_gui, "reload_theme", lambda then=None: reloads.append([name for name, _ in then])) + + choose(screen_gui, monkeypatch, 'default_theme', tag='themes_list') + + assert reloads == [['option', 'select-theme']] + assert screen_gui.hm.cfg_data['theme'] == 'default_theme' + assert screen_gui.hm.cfg_data['theme-scheme'] is None + + +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") + + reloads = [] + monkeypatch.setattr(screen_gui, "reload_theme", lambda then=None: reloads.append(then)) + 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 reloads == [] + + 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): + reloads = [] + monkeypatch.setattr(screen_gui, "reload_theme", lambda then=None: reloads.append([name for name, _ in then])) + 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 reloads == [['option', 'select-scheme']] + + +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 From 88366f943ee714c83158b18a7227b5a2bd925fec Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 17:02:29 +0800 Subject: [PATCH 07/13] Tests for pugwash's main() and input events; --no-log without a log file - `harbourmaster --no-log` with no PortMaster folder at import raised NameError (LOG_FILE_HANDLE was never set), which logger.catch swallowed: the command never ran. - pugwash: the same case called logger.remove(None), which removes every handler, stderr's too. - EventManager.buttons was seeded with SDL keycodes and axis names instead of button names, so SELECT, SCRN and the stick directions were missing until first pressed. New tests: test_main.py (flags, logging, offline detection, fifo_control, the update check, the reboot file, the frontend refresh), test_events.py (keyboard, controller buttons, sticks and triggers, key repeat, hotplug, the xbox fix, pushed onto SDL's real event queue). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/harbourmaster/cli.py | 4 +- PortMaster/pylibs/pugwash/main.py | 8 +- PortMaster/pylibs/pugwash/sdl/events.py | 7 +- tests/test_cli_commands.py | 12 ++ tests/test_events.py | 257 ++++++++++++++++++++++++ tests/test_main.py | 205 +++++++++++++++++++ 6 files changed, 487 insertions(+), 6 deletions(-) create mode 100644 tests/test_events.py create mode 100644 tests/test_main.py diff --git a/PortMaster/pylibs/harbourmaster/cli.py b/PortMaster/pylibs/harbourmaster/cli.py index fc3979dd..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) @@ -612,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/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/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/tests/test_cli_commands.py b/tests/test_cli_commands.py index f2564174..1abb2575 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -463,3 +463,15 @@ def test_main_debug_flag_file(run_main, monkeypatch, tmp_path): 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_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_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'] From e30d6f805303f7fa7f0b03c31faef24ac4f8fd07 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 17:06:42 +0800 Subject: [PATCH 08/13] Tests for the SDL layer's helpers; a theme colour typo no longer crashes hex_color_decode() raised ValueError on a colour that isn't hex ("#ggg" in a custom theme); it is white now, as a bad length already was. It also handed out the same cached list to every caller, it's a tuple now. test_sdl_helpers.py: Rect maths (fit, clip, inflate, edges, scaling), theme areas (NamedRects: fractions, pixels, negative offsets, errors), colours, dict merging, autoscroll, timers and animations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/pugwash/sdl/helpers.py | 14 +- tests/test_sdl_helpers.py | 358 +++++++++++++++++++++++ 2 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 tests/test_sdl_helpers.py 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/tests/test_sdl_helpers.py b/tests/test_sdl_helpers.py new file mode 100644 index 00000000..6d817f31 --- /dev/null +++ b/tests/test_sdl_helpers.py @@ -0,0 +1,358 @@ +# 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" + + manager.clear() + assert manager._animations == {} From fc350816939323e1f288ab4e67d5c9e0a4d8c105 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 17:10:23 +0800 Subject: [PATCH 09/13] fifo_control always answers; tests for every fifo command and the loop - A fifo command that raised ended fifo_control with the done file on WAIT, so the shell script waited for good. Each command now answers FAIL instead and the loop carries on. - register_clear_info unpacked three values from two: it always raised. - progress put its "data"/"%" format into the total: the format was never used, and with a non-numeric total it crashed comparing an int and a str. - do_fifo_control checked the length of its second path, not of its arguments: one argument raised IndexError. test_fifo_commands.py: registers, messages, progress, message_box and selection_list option parsing, and the do_fifo_control loop driven through a real named pipe, as PortMasterDialog.txt drives it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/pugwash/app_fifo.py | 15 +- tests/test_fifo_commands.py | 287 ++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 tests/test_fifo_commands.py 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/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() From ce3c525b42bbf0de1c0b64d987641e889ea36950 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 17:14:54 +0800 Subject: [PATCH 10/13] A broken custom theme no longer stops PortMaster starting - The selected theme's theme.json cut off or hand-edited into bad JSON: PortMaster crashed on every start, and reinstalling didn't help (themes survive it). It now falls back to the default theme, as for a missing one. - A theme that is valid JSON but fails to load (eg: a typo in an element's area) likewise: ThemeEngine.gui_init() uses the default theme instead. A forced theme (the theme editor) still raises, so the error shows. - Any installed theme with a broken theme.json took the theme list down; it's left out now. test_theme_engine.py: requirement tags in theme keys, theme_update/merge, the installed and downloadable themes list (updates, images, order), the current theme's fallbacks, forced themes and schemes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/pugwash/theme.py | 45 +++++- tests/test_theme_engine.py | 237 +++++++++++++++++++++++++++++ 2 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 tests/test_theme_engine.py diff --git a/PortMaster/pylibs/pugwash/theme.py b/PortMaster/pylibs/pugwash/theme.py index acc1dc22..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 @@ -587,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 @@ -650,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 @@ -691,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/tests/test_theme_engine.py b/tests/test_theme_engine.py new file mode 100644 index 00000000..b4dc27fa --- /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 +import shutil +from types import SimpleNamespace + +import pytest + +import harbourmaster + +from conftest import PM_DIR + + +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_and_scheme(theme, hm): + engine = theme.ThemeEngine(SimpleNamespace(hm=hm), force_theme="forced", force_scheme="dark") + assert engine.get_current_theme() == "forced" + assert engine.get_current_theme_scheme() == "dark" + + +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 app(gui, hm_dirs): + gui.hm = harbourmaster.HarbourMaster({'offline': True, 'no-check': True}, callback=gui, **hm_dirs) + yield gui + + gui.themes.force_theme = "default_theme" + gui.hm.cfg_data['theme'] = "default_theme" + gui.reload_theme() + gui.hm = None + + +@pytest.fixture +def typo_theme(): + """A copy of the default theme with a typo: an element's area has three numbers.""" + theme_dir = harbourmaster.HM_TOOLS_DIR / "PortMaster" / "themes" / "Typo" + shutil.copytree(str(PM_DIR / "pylibs" / "default_theme"), str(theme_dir)) + data = json.loads((theme_dir / "theme.json").read_text()) + data["#elements"]["left_pane"] = dict(data["#elements"]["left_pane"], area=[0, 0, 100]) + (theme_dir / "theme.json").write_text(json.dumps(data)) + yield "Typo" + shutil.rmtree(str(theme_dir)) + + +def test_theme_that_fails_to_load_falls_back(app, typo_theme, log_errors): + ## It stopped PortMaster from starting until the theme was deleted by hand. + app.themes.force_theme = None + app.hm.cfg_data['theme'] = typo_theme + + app.reload_theme() + + assert app.themes.get_current_theme() == "default_theme" + assert app.theme_dir == PM_DIR / "pylibs" / "default_theme" + assert 'option_menu' in app.theme_data + assert any("using the default theme" in message for message in log_errors) + + +def test_forced_theme_that_fails_to_load_raises(app, typo_theme): + ## The theme editor shows the error rather than another theme. + from pugwash.sdl.errors import GUIThemeError + app.themes.force_theme = typo_theme + + with pytest.raises(GUIThemeError): + app.reload_theme() From d0e678991c5227011a86cb28ac9ed3e0aae70901 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 18:59:28 +0800 Subject: [PATCH 11/13] Tests: main's theme, scheme and language choosing; choosing the default language The tests came from the beta branch, where these apply at once (reload_theme / reload_language) and there are port links and a theme editor. Here they ask, save, and quit to apply at the next start, so the tests check that instead. The theme fallback is tested with Theme.gui_init stood in for, which needs no reloading. Choosing the default language crashed (KeyError) when the language came from the system rather than the config: del cfg_data['language'] is a pop now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/pugwash/scenes/language.py | 3 +- tests/test_gui_scenes.py | 46 ++++++++----- tests/test_gui_screens.py | 43 +++++------- tests/test_sdl_helpers.py | 3 - tests/test_theme_engine.py | 70 ++++++++++---------- 5 files changed, 86 insertions(+), 79 deletions(-) diff --git a/PortMaster/pylibs/pugwash/scenes/language.py b/PortMaster/pylibs/pugwash/scenes/language.py index 46a6b3cd..332a3c5b 100644 --- a/PortMaster/pylibs/pugwash/scenes/language.py +++ b/PortMaster/pylibs/pugwash/scenes/language.py @@ -52,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/tests/test_gui_scenes.py b/tests/test_gui_scenes.py index aadfbec5..bd116627 100644 --- a/tests/test_gui_scenes.py +++ b/tests/test_gui_scenes.py @@ -407,7 +407,7 @@ def option_text(scene, option): def test_options_list(scene_gui, options): ids = options.tags['option_list'].options - for option in ('select-language', 'select-theme', 'theme-editor', 'toggle-music', 'toggle-sfx', + 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 @@ -687,25 +687,41 @@ def test_language_selected_row_does_nothing(scene_gui, language, monkeypatch): 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): - reloaded = [] - monkeypatch.setattr(scene_gui, "reload_language", lambda code, then=None: reloaded.append((code, [name for name, _ in then]))) + ## 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] - region = language.tags['option_list'] - others = [option for option in region.options if isinstance(option, tuple) and option[0] == 'select-language'] - assert others + 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 - select(language, others[0]) - scene_gui.do_loop(no_delay=True) - press(scene_gui, monkeypatch, 'A') - code = others[0][1] - assert reloaded == [(code, ['option', 'select-language'])] +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 - if code == lang.DEFAULT_LANG: - assert 'language' not in scene_gui.hm.cfg_data - else: - assert scene_gui.hm.cfg_data['language'] == code + 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): diff --git a/tests/test_gui_screens.py b/tests/test_gui_screens.py index 67e38385..a0ed5c91 100644 --- a/tests/test_gui_screens.py +++ b/tests/test_gui_screens.py @@ -331,16 +331,6 @@ def test_port_info_popup(popup_theme, monkeypatch): assert not info.popup_shown -def test_port_info_popup_links(popup_theme, monkeypatch): - screen_gui = popup_theme - popup_info(screen_gui, "sprout.zip") - - press(screen_gui, monkeypatch, 'UP') - popup = top(screen_gui) - assert "Links" in bar(popup) - - press(screen_gui, monkeypatch, 'X') - assert screen_gui.scene_list()[-1] == "port_links" def test_port_info_popup_follows_the_port(popup_theme, monkeypatch): @@ -354,11 +344,6 @@ def test_port_info_popup_follows_the_port(popup_theme, monkeypatch): assert popup.port_name == "something else" -def test_port_info_without_links(screen_gui, info, monkeypatch): - ## 2048plus has no stores, and the website link is only for the official sources' ports. - screen_gui.set_data("port_info.links", "") - assert not info.has_links() - assert info.show_links() is False def test_port_info_without_harbourmaster(screen_gui, info): @@ -380,6 +365,9 @@ def test_runtime_list(screen_gui, runtimes): ## 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') == "" @@ -493,14 +481,23 @@ def test_themes_list(screen_gui, themes): def test_themes_select(screen_gui, themes, monkeypatch): - reloads = [] - monkeypatch.setattr(screen_gui, "reload_theme", lambda then=None: reloads.append([name for name, _ in then])) - + ## 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 reloads == [['option', 'select-theme']] + 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): @@ -510,8 +507,6 @@ def test_themes_not_installed_cant_be_selected(screen_gui, themes, monkeypatch): monkeypatch.setattr(screen_gui.themes, "get_themes_list", lambda *args: listed) themes.tags['themes_list'].add_option('remote_theme', "Remote") - reloads = [] - monkeypatch.setattr(screen_gui, "reload_theme", lambda then=None: reloads.append(then)) installs = [] monkeypatch.setattr(screen_gui, "do_install", lambda name, url: installs.append((name, url))) @@ -520,7 +515,7 @@ def test_themes_not_installed_cant_be_selected(screen_gui, themes, monkeypatch): assert "Download" in bar(themes) and "Select" not in bar(themes) press(screen_gui, monkeypatch, 'A') - assert reloads == [] + assert screen_gui.boxes == [] press(screen_gui, monkeypatch, 'X') assert installs == [("Remote", "https://example.com/remote.theme.zip.md5")] @@ -555,14 +550,12 @@ def test_schemes_list(screen_gui, schemes): def test_schemes_choose(screen_gui, schemes, monkeypatch): - reloads = [] - monkeypatch.setattr(screen_gui, "reload_theme", lambda then=None: reloads.append([name for name, _ in then])) 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 reloads == [['option', 'select-scheme']] + assert screen_gui.events.running is False def test_schemes_back_and_blank(screen_gui, schemes, monkeypatch): diff --git a/tests/test_sdl_helpers.py b/tests/test_sdl_helpers.py index 6d817f31..74d7b2e2 100644 --- a/tests/test_sdl_helpers.py +++ b/tests/test_sdl_helpers.py @@ -353,6 +353,3 @@ def test_animation_scene_change(pugwash, ticks): manager.change_scene() assert gui.data["animation.resets"] == "000" assert gui.data["animation.keeps"] == "002" - - manager.clear() - assert manager._animations == {} diff --git a/tests/test_theme_engine.py b/tests/test_theme_engine.py index b4dc27fa..7fca99b5 100644 --- a/tests/test_theme_engine.py +++ b/tests/test_theme_engine.py @@ -5,14 +5,12 @@ # PortMaster from starting). import json -import shutil from types import SimpleNamespace import pytest import harbourmaster -from conftest import PM_DIR pytestmark = pytest.mark.sdl @@ -164,10 +162,9 @@ def test_current_theme(engine, hm): assert engine.get_current_theme() == "default_theme" -def test_forced_theme_and_scheme(theme, hm): - engine = theme.ThemeEngine(SimpleNamespace(hm=hm), force_theme="forced", force_scheme="dark") +def test_forced_theme(theme, hm): + engine = theme.ThemeEngine(SimpleNamespace(hm=hm), force_theme="forced") assert engine.get_current_theme() == "forced" - assert engine.get_current_theme_scheme() == "dark" def test_config_without_harbourmaster(theme, hm, monkeypatch): @@ -193,45 +190,48 @@ def test_theme_missing(theme, tmp_path): ################################################################################ ## A custom theme that loads as JSON but not as a theme @pytest.fixture -def app(gui, hm_dirs): - gui.hm = harbourmaster.HarbourMaster({'offline': True, 'no-check': True}, callback=gui, **hm_dirs) - yield gui +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 = [] - gui.themes.force_theme = "default_theme" - gui.hm.cfg_data['theme'] = "default_theme" - gui.reload_theme() - gui.hm = None + 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} -@pytest.fixture -def typo_theme(): - """A copy of the default theme with a typo: an element's area has three numbers.""" - theme_dir = harbourmaster.HM_TOOLS_DIR / "PortMaster" / "themes" / "Typo" - shutil.copytree(str(PM_DIR / "pylibs" / "default_theme"), str(theme_dir)) - data = json.loads((theme_dir / "theme.json").read_text()) - data["#elements"]["left_pane"] = dict(data["#elements"]["left_pane"], area=[0, 0, 100]) - (theme_dir / "theme.json").write_text(json.dumps(data)) - yield "Typo" - shutil.rmtree(str(theme_dir)) - - -def test_theme_that_fails_to_load_falls_back(app, typo_theme, log_errors): - ## It stopped PortMaster from starting until the theme was deleted by hand. - app.themes.force_theme = None - app.hm.cfg_data['theme'] = typo_theme + 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 - app.reload_theme() - assert app.themes.get_current_theme() == "default_theme" - assert app.theme_dir == PM_DIR / "pylibs" / "default_theme" - assert 'option_menu' in app.theme_data +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(app, typo_theme): +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 - app.themes.force_theme = typo_theme + loading.force_theme = "Typo" with pytest.raises(GUIThemeError): - app.reload_theme() + 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() From 481ed9d2372742b5ef3c520faed47a799fa10bf4 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 19:47:44 +0800 Subject: [PATCH 12/13] Sources: "Update ports" always fetches ports.json Even if it was fetched when PortMaster started: a release may have been made since. It used to stop at "Up to date already". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- PortMaster/pylibs/harbourmaster/source/portmaster.py | 8 +------- tests/test_source.py | 10 ++++++---- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/PortMaster/pylibs/harbourmaster/source/portmaster.py b/PortMaster/pylibs/harbourmaster/source/portmaster.py index e719d7aa..32f023df 100644 --- a/PortMaster/pylibs/harbourmaster/source/portmaster.py +++ b/PortMaster/pylibs/harbourmaster/source/portmaster.py @@ -41,12 +41,7 @@ def update(self): if self.hm.callback is not None: self.hm.callback.message(" - {}".format(_("Updating"))) - ## Already fetched this run: keep what's loaded. Clearing it first emptied the - ## port list on "Update ports" right after the startup update. - if self._did_update: - self.hm.callback.message(" - {}".format(_("Up to date already"))) - return - + ## 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"))) @@ -136,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/tests/test_source.py b/tests/test_source.py index cab9a813..daf74bd7 100644 --- a/tests/test_source.py +++ b/tests/test_source.py @@ -85,13 +85,15 @@ def test_update_failure_keeps_going(hm, monkeypatch): assert source.ports == [] -def test_update_again_keeps_the_ports(pm_source, ports_json_data): - ## "Update ports" after the startup update used to empty the list until a restart. +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']] + 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'] + 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): From 314ce97b69293d4978161a6720ca0331bbc19547 Mon Sep 17 00:00:00 2001 From: kloptops Date: Wed, 21 Oct 2026 19:56:11 +0800 Subject: [PATCH 13/13] Tests: the CLI fifo_control test waited for its pipe with a race It checked exists() then is_file(): if harbourmaster removed the stale file in between, the test took the missing file for the pipe, and its open() made a plain file there, so mkfifo failed with FileExistsError (seen on Python 3.13 in CI). One stat() and S_ISFIFO instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NxABmR1rUUgi4iicVLZchY --- tests/test_cli_commands.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 1abb2575..baa55b9e 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -344,9 +344,20 @@ def test_fifo_control_commands(cli_hm, tmp_path, monkeypatch): 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 (fifo.exists() and not fifo.is_file()) and time.monotonic() < end: + 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: