diff --git a/src/tests/test_app_log.py b/src/tests/test_app_log.py new file mode 100644 index 0000000..85f6fa3 --- /dev/null +++ b/src/tests/test_app_log.py @@ -0,0 +1,26 @@ +import queue +from src import app_log + + +def test_log_enqueues_message_and_tag(monkeypatch): + # Arrange + q = queue.Queue() + monkeypatch.setattr(app_log, "LOG_QUEUE", q) + + # Act + app_log.log("ready", "ok") + + # Assert + assert q.get_nowait() == ("ready", "ok") + + +def test_log_uses_empty_tag_by_default(monkeypatch): + # Arrange + q = queue.Queue() + monkeypatch.setattr(app_log, "LOG_QUEUE", q) + + # Act + app_log.log("ready") + + # Assert + assert q.get_nowait() == ("ready", "") diff --git a/src/tests/test_client.py b/src/tests/test_client.py new file mode 100644 index 0000000..6c68174 --- /dev/null +++ b/src/tests/test_client.py @@ -0,0 +1,152 @@ +import math +from pathlib import Path +from unittest.mock import Mock +import pytest +from src import client + + +def test_remove_wdb_does_nothing_when_directory_is_absent(tmp_path, monkeypatch): + log = Mock() + monkeypatch.setattr(client, "log", log) + client.remove_wdb(str(tmp_path)) + log.assert_not_called() + + +def test_remove_wdb_removes_existing_cache(tmp_path, monkeypatch): + wdb = tmp_path / "WDB" + wdb.mkdir() + (wdb / "cache.bin").write_bytes(b"x") + log = Mock() + monkeypatch.setattr(client, "log", log) + + client.remove_wdb(str(tmp_path)) + + assert not wdb.exists() + log.assert_called_once_with("WDB cache cleared.", "dim") + + +def test_remove_wdb_logs_failure_without_raising(tmp_path, monkeypatch): + (tmp_path / "WDB").mkdir() + monkeypatch.setattr(client.shutil, "rmtree", Mock(side_effect=PermissionError("locked"))) + log = Mock() + monkeypatch.setattr(client, "log", log) + + client.remove_wdb(str(tmp_path)) + + assert "locked" in log.call_args.args[0] + assert log.call_args.args[1] == "err" + + +def test_get_client_version_returns_empty_when_executable_is_missing(tmp_path): + assert client.get_client_version(str(tmp_path)) == "" + + +def test_get_client_version_reads_valid_version_and_build(tmp_path): + exe = tmp_path / "WoW.exe" + data = bytearray(0x00437c0a) + data[0x00437bfc:0x00437c00] = b"5875" + data[0x00437c04:0x00437c0a] = b"1.12.1" + exe.write_bytes(data) + + assert client.get_client_version(str(tmp_path)) == "1.12.1 (5875)" + + +def test_get_client_version_rejects_invalid_version_bytes(tmp_path): + exe = tmp_path / "WoW.exe" + data = bytearray(0x00437c0a) + data[0x00437bfc:0x00437c00] = b"5875" + data[0x00437c04:0x00437c0a] = b"broken" + exe.write_bytes(data) + + assert client.get_client_version(str(tmp_path)) == "" + + +@pytest.mark.parametrize(("value", "expected"), [ + (0, "0 KB"), (1023, "0 KB"), (1024, "1 KB"), + (1024**2, "1.0 MB"), (1024**3, "1.00 GB"), +]) +def test_fmt_size_formats_boundaries(value, expected): + assert client.fmt_size(value) == expected + + +def test_fmt_size_truncates_gigabytes_instead_of_rounding_up(): + assert client.fmt_size(8.7998 * 1024**3) == "8.79 GB" + + +@pytest.mark.parametrize(("value", "expected"), [ + (0, "0 KB/s"), (1024, "1 KB/s"), + (1024**2 - 1, "1024 KB/s"), (1024**2, "1.0 MB/s"), +]) +def test_fmt_speed_formats_boundaries(value, expected): + assert client.fmt_speed(value) == expected + + +def test_locale_patches_defaults_unknown_locale_to_english(): + assert client.locale_patches("not-a-locale") == client.locale_patches("enUS") + + +def test_locale_patches_renames_reused_slot_for_russian(): + patches = client.locale_patches("ruRU") + assert patches[-1] == (client._LOCALE_NAME_OFFSET(5), b"ruRU") + + +def test_update_config_wtf_creates_config_when_missing(tmp_path, monkeypatch): + writer = Mock() + monkeypatch.setattr(client, "write_config_wtf", writer) + tweaks = {"farClip": 500} + + client.update_config_wtf(str(tmp_path), tweaks) + + writer.assert_called_once_with(str(tmp_path), tweaks) + + +def test_update_config_wtf_updates_existing_values_and_preserves_unrelated_lines(tmp_path, monkeypatch): + cfg_dir = tmp_path / "WTF" + cfg_dir.mkdir() + cfg = cfg_dir / "Config.wtf" + cfg.write_text('SET farClip "100"\nSET untouched "yes"\n', encoding="utf-8") + monkeypatch.setattr(client, "log", Mock()) + + client.update_config_wtf(str(tmp_path), {"farClip": 777, "cameraDistance": 50, + "nameplateRange": 41, "fieldOfView": 110, "soundInBackground": True}) + + text = cfg.read_text(encoding="utf-8") + assert 'SET farClip "777"' in text + assert 'SET untouched "yes"' in text + assert 'SET CameraDistanceMax "50"' in text + + +def test_fov_default_for_display_uses_lowest_reference_for_narrow_display(monkeypatch): + monkeypatch.setattr(client, "_get_display_info_safe", lambda: {"width": 1024, "height": 1000}) + assert client.fov_default_for_display() == 90 + + +def test_fov_default_for_display_uses_highest_reference_for_ultrawide(monkeypatch): + monkeypatch.setattr(client, "_get_display_info_safe", lambda: {"width": 5120, "height": 1080}) + assert client.fov_default_for_display() == 180 + + +def test_fov_default_for_display_falls_back_to_16_9_when_display_lookup_fails(monkeypatch): + monkeypatch.setattr(client, "_get_display_info_safe", Mock(side_effect=RuntimeError("display unavailable"))) + assert client.fov_default_for_display() == 110 + + +def test_load_tweaks_config_merges_stored_values_with_defaults(monkeypatch): + monkeypatch.setattr(client, "load_config", lambda: {"tweaks": {"farClip": 999}}) + monkeypatch.setattr(client, "fov_default_for_display", lambda: 150) + + result = client.load_tweaks_config() + + assert result["farClip"] == 999 + assert result["fieldOfView"] == 150 + assert result["cameraDistance"] == client.TWEAKS_DEFAULTS["cameraDistance"] + + +def test_save_tweaks_config_stores_values(monkeypatch): + cfg = {} + monkeypatch.setattr(client, "update_config", lambda fn: fn(cfg)) + values = {"farClip": 900} + + client.save_tweaks_config(values) + + assert cfg["tweaks"] == values diff --git a/src/tests/test_mods.py b/src/tests/test_mods.py new file mode 100644 index 0000000..20724ed --- /dev/null +++ b/src/tests/test_mods.py @@ -0,0 +1,221 @@ +import errno +import urllib.error +import zipfile +from unittest.mock import Mock +import pytest +from src import mods + + +@pytest.mark.parametrize(("value", "expected"), [ + ("v1.2.0", (1, 2, 0)), ("V2.5-beta3", (2, 53)), + ("", (0,)), ("release", (0,)), +]) +def test_parse_version_extracts_numeric_components(value, expected): + assert mods._parse_version(value) == expected + + +def test_updater_update_available_returns_false_without_latest_tag(): + assert mods.updater_update_available("") is False + + +def test_updater_update_available_zero_pads_equivalent_versions(monkeypatch): + monkeypatch.setattr(mods, "UPDATER_VERSION", "1.2.0") + assert mods.updater_update_available("v1.2") is False + + +def test_updater_update_available_detects_newer_version(monkeypatch): + monkeypatch.setattr(mods, "UPDATER_VERSION", "1.2.0") + assert mods.updater_update_available("v1.2.1") is True + + +def test_pick_asset_returns_first_matching_asset(): + assets = [{"name": "mod.txt"}, {"name": "mod.zip"}] + assert mods._pick_asset(assets, "*.zip", None) == assets[1] + + +def test_pick_asset_prefers_name_without_excluded_text(): + assets = [{"name": "mod-dxvk.zip"}, {"name": "mod.zip"}] + assert mods._pick_asset(assets, "*.zip", "-dxvk") == assets[1] + + +def test_pick_asset_returns_none_when_nothing_matches(): + assert mods._pick_asset([{"name": "readme.txt"}], "*.zip", None) is None + + +def test_release_version_uses_tag_by_default(): + mod = {"source": {"kind": "github_release"}} + assert mods._release_version(mod, {"tag_name": "v2.0"}) == "v2.0" + + +def test_release_version_can_extract_version_from_asset_name(): + mod = {"source": {"version_from": "asset", "asset_pattern": "*.zip", "prefer_no": None}} + rel = {"assets": [{"name": "SuperWoW-1.4.2.zip"}], "tag_name": "static"} + assert mods._release_version(mod, rel) == "1.4.2" + + +def test_slim_release_discards_unused_release_fields(): + rel = {"tag_name": "v1", "body": "large", "assets": [{"name": "x.zip", "size": 12, + "browser_download_url": "https://example/x", "extra": "drop"}]} + assert mods._slim_release(rel) == {"tag_name": "v1", "assets": [{"name": "x.zip", "size": 12, + "browser_download_url": "https://example/x"}]} + + +def test_add_dll_creates_dlls_file(tmp_path): + mods.add_dll(str(tmp_path), "Test.dll") + assert (tmp_path / "dlls.txt").read_text() == "Test.dll\n" + + +def test_add_dll_does_not_add_case_insensitive_duplicate(tmp_path): + path = tmp_path / "dlls.txt" + path.write_text("Test.dll\n") + mods.add_dll(str(tmp_path), "test.DLL") + assert path.read_text() == "Test.dll\n" + + +def test_remove_dll_removes_named_entry_case_insensitively(tmp_path): + path = tmp_path / "dlls.txt" + path.write_text("One.dll\nTwo.dll\n") + mods.remove_dll(str(tmp_path), "ONE.DLL") + assert path.read_text() == "Two.dll\n" + + +def test_remove_dll_deletes_empty_dlls_file(tmp_path): + path = tmp_path / "dlls.txt" + path.write_text("One.dll\n") + mods.remove_dll(str(tmp_path), "one.dll") + assert not path.exists() + + +def test_remove_dll_does_nothing_when_file_is_missing(tmp_path): + mods.remove_dll(str(tmp_path), "missing.dll") + assert not (tmp_path / "dlls.txt").exists() + + +def test_mod_installed_files_present_requires_nonempty_installed_file_list(tmp_path, monkeypatch): + monkeypatch.setattr(mods, "load_config", lambda: {"mods": {"m": {"installed_files": []}}}) + assert mods.mod_installed_files_present({"id": "m"}, str(tmp_path)) is False + + +def test_mod_installed_files_present_returns_true_when_every_file_exists(tmp_path, monkeypatch): + (tmp_path / "a.dll").write_text("") + (tmp_path / "b.dll").write_text("") + monkeypatch.setattr(mods, "load_config", lambda: {"mods": {"m": {"installed_files": ["a.dll", "b.dll"]}}}) + assert mods.mod_installed_files_present({"id": "m"}, str(tmp_path)) is True + + +@pytest.mark.parametrize("kind", ["direct_file", "direct_tar"]) +def test_mod_supports_update_check_rejects_pinned_direct_sources(kind): + assert mods.mod_supports_update_check({"source": {"kind": kind}}) is False + + +def test_mod_update_available_requires_enabled_mod(): + mod = {"source": {"kind": "github_release"}} + state = {"enabled": False, "installed_version": "1"} + assert mods.mod_update_available(mod, state, {"latest_version": "2"}) is False + + +def test_mod_update_available_respects_ignore_updates(): + mod = {"source": {"kind": "github_release"}} + state = {"enabled": True, "ignore_updates": True, "installed_version": "1"} + assert mods.mod_update_available(mod, state, {"latest_version": "2"}) is False + + +def test_mod_update_available_detects_different_live_version(): + mod = {"source": {"kind": "github_release"}} + state = {"enabled": True, "installed_version": "1"} + assert mods.mod_update_available(mod, state, {"latest_version": "2"}) is True + + +def test_same_git_repo_ignores_case_trailing_slash_and_dot_git(): + assert mods._same_git_repo("https://github.com/Owner/Repo.git/", "https://github.com/owner/repo") is True + + +@pytest.mark.parametrize("url", [ + "https://github.com/owner/repo", "https://sub.gitlab.com/owner/repo", + "https://codeberg.org/owner/repo", +]) +def test_is_allowed_git_url_accepts_supported_https_hosts(url): + assert mods.is_allowed_git_url(url) is True + + +@pytest.mark.parametrize("url", ["http://github.com/a/b", "https://example.com/a/b", "not a url"]) +def test_is_allowed_git_url_rejects_unsupported_urls(url): + assert mods.is_allowed_git_url(url) is False + + +def test_slim_addon_catalog_keeps_only_supported_fields(): + catalog = [{"name": "A", "git": "g", "branch": "main", "ref": None, "description": "d", + "extra": 1, "toc": {"Title": "T", "Notes": "N", "Interface": "11200", "Other": "drop"}}] + result = mods._slim_addon_catalog(catalog) + assert result == [{"name": "A", "git": "g", "branch": "main", "ref": None, "description": "d", + "toc": {"Title": "T", "Notes": "N", "Interface": "11200"}}] + + +def test_read_toc_file_parses_metadata_and_ignores_other_lines(tmp_path): + path = tmp_path / "Addon.toc" + path.write_text("\ufeff## Title: My Addon\nfile.lua\n## Interface: 11200\n", encoding="utf-8") + assert mods.read_toc_file(str(path)) == {"Title": "My Addon", "Interface": "11200"} + + +def test_read_toc_file_returns_empty_dict_when_missing(tmp_path): + assert mods.read_toc_file(str(tmp_path / "missing.toc")) == {} + + +def test_parse_wow_colored_splits_colored_and_plain_segments(): + text = "plain |cffff0000red|r normal" + assert mods.parse_wow_colored(text) == [("plain ", None), ("red", "#ff0000"), (" normal", None)] + + +def test_strip_wow_colors_removes_color_escape_sequences(): + assert mods.strip_wow_colors("A |cff00ff00green|r B") == "A green B" + + +def test_git_parts_parses_github_dot_git_url(): + kind, repo_url, owner, repo, api = mods._git_parts("https://github.com/Owner/Repo.git") + assert (kind, repo_url, owner, repo) == ("github", "https://github.com/Owner/Repo", "Owner", "Repo") + assert api == mods.GITHUB_API + + +def test_git_parts_parses_prefixed_gitea_url(): + kind, repo_url, owner, repo, api = mods._git_parts("https://octowow.st/git/Microchip/octo-updater") + assert kind == "gitea" + assert repo_url == "https://octowow.st/git/Microchip/octo-updater" + assert api == "https://octowow.st/git/api/v1" + + +def test_git_parts_rejects_url_without_owner_and_repo(): + with pytest.raises(ValueError, match="Unsupported git URL"): + mods._git_parts("https://github.com/onlyone") + + +def test_describe_net_error_maps_http_403_to_rate_limit_message(): + e = urllib.error.HTTPError("https://api.github.com/x", 403, "Forbidden", {}, None) + assert "rate limit" in mods._describe_net_error(e) + + +def test_describe_net_error_maps_http_404_to_not_found_message(): + e = urllib.error.HTTPError("https://api.github.com/x", 404, "Not Found", {}, None) + assert mods._describe_net_error(e) == "repository or branch not found" + + +def test_describe_install_error_maps_antivirus_like_os_errors(): + e = OSError(errno.EACCES, "denied") + assert "antivirus" in mods.describe_install_error(e).lower() + + +def test_describe_install_error_maps_bad_zip_file(): + assert "corrupted" in mods.describe_install_error(zipfile.BadZipFile()).lower() + + +def test_addon_cached_sha_returns_cached_value_without_network(monkeypatch): + key = "https://github.com/a/b#main" + monkeypatch.setattr(mods, "load_config", lambda: {"addon_sha_cache": {key: {"sha": "abc"}}}) + assert mods.addon_cached_sha("https://github.com/a/b", branch="main") == "abc" + + +def test_addon_zip_url_builds_github_archive_url(): + assert mods.addon_zip_url("https://github.com/a/b", "abc") == "https://github.com/a/b/archive/abc.zip" + + +def test_addon_zip_url_builds_gitlab_archive_url(): + assert mods.addon_zip_url("https://gitlab.com/a/b", "abc") == "https://gitlab.com/a/b/-/archive/abc/b-abc.zip" diff --git a/src/tests/test_net.py b/src/tests/test_net.py new file mode 100644 index 0000000..ed2ffb8 --- /dev/null +++ b/src/tests/test_net.py @@ -0,0 +1,62 @@ +import urllib.request +from unittest.mock import Mock +import pytest +from src import net + + +def test_check_url_accepts_https_when_no_allowlist(): + net._check_url("https://example.com/file", None) + + +def test_check_url_rejects_http(): + with pytest.raises(RuntimeError, match="non-HTTPS"): + net._check_url("http://example.com/file", None) + + +def test_check_url_accepts_allowlisted_host_case_insensitively(): + net._check_url("https://EXAMPLE.COM/file", {"example.com"}) + + +def test_check_url_rejects_host_not_in_allowlist(): + with pytest.raises(RuntimeError, match="unexpected host"): + net._check_url("https://evil.example/file", {"example.com"}) + + +def test_check_url_does_not_treat_subdomain_as_exact_allowlist_match(): + with pytest.raises(RuntimeError, match="unexpected host"): + net._check_url("https://cdn.example.com/file", {"example.com"}) + + +def test_secure_urlopen_opens_valid_url_with_timeout(monkeypatch): + # Arrange + response = object() + opener = Mock() + opener.open.return_value = response + monkeypatch.setattr(net, "_SECURE_OPENER", opener) + + # Act + result = net.secure_urlopen("https://example.com/file", 7, {"example.com"}) + + # Assert + assert result is response + opener.open.assert_called_once_with("https://example.com/file", timeout=7) + + +def test_secure_urlopen_validates_request_full_url_before_opening(monkeypatch): + # Arrange + opener = Mock() + monkeypatch.setattr(net, "_SECURE_OPENER", opener) + req = urllib.request.Request("http://example.com/file") + + # Act / Assert + with pytest.raises(RuntimeError, match="non-HTTPS"): + net.secure_urlopen(req, 5) + opener.open.assert_not_called() + + +def test_redirect_handler_rejects_https_to_http_downgrade(): + handler = net._HttpsOnlyRedirectHandler() + req = urllib.request.Request("https://example.com/start") + + with pytest.raises(RuntimeError, match="non-HTTPS"): + handler.redirect_request(req, None, 302, "Found", {}, "http://example.com/end") diff --git a/src/tests/test_octo_updater.py b/src/tests/test_octo_updater.py new file mode 100644 index 0000000..9d082cd --- /dev/null +++ b/src/tests/test_octo_updater.py @@ -0,0 +1,15 @@ +from unittest.mock import Mock +import octo_updater + + +def test_main_prepares_platform_and_data_before_starting_ui(monkeypatch): + calls = [] + monkeypatch.setattr(octo_updater, "enable_dpi_awareness", lambda: calls.append("dpi")) + monkeypatch.setattr(octo_updater, "_relocate_legacy_data", lambda: calls.append("relocate")) + app = Mock() + monkeypatch.setattr(octo_updater, "OctoUpdaterApp", lambda: calls.append("construct") or app) + + octo_updater.main() + + assert calls == ["dpi", "relocate", "construct"] + app.mainloop.assert_called_once_with() diff --git a/src/tests/test_ui.py b/src/tests/test_ui.py new file mode 100644 index 0000000..27d7e44 --- /dev/null +++ b/src/tests/test_ui.py @@ -0,0 +1,58 @@ +import io +import json +from unittest.mock import Mock +from src import ui + + +class JsonResponse(io.BytesIO): + def __enter__(self): return self + def __exit__(self, *args): return False + + +def test_strip_html_removes_tags_scripts_and_decodes_entities(): + raw = '
Hello & goodbye