Run library, checks and downloads in background threads with parallel fetching
This commit is contained in:
+23
-116
@@ -1,141 +1,48 @@
|
||||
"""Tests for constants module."""
|
||||
"""Tests for the constants module."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from src.constants import (
|
||||
APP_NAME,
|
||||
APP_TITLE,
|
||||
APP_VERSION,
|
||||
ENV_DEBUG,
|
||||
get_debug_mode,
|
||||
get_version,
|
||||
VERSION,
|
||||
_load_debug,
|
||||
_load_version,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_version()
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_load_version_returns_string() -> None:
|
||||
assert isinstance(_load_version(), str)
|
||||
|
||||
|
||||
def test_get_version_returns_string() -> None:
|
||||
"""get_version() should return a string."""
|
||||
assert isinstance(get_version(), str)
|
||||
def test_load_version_semver_format() -> None:
|
||||
assert re.match(r"^\d+\.\d+\.\d+", _load_version()), f"Not semver: {_load_version()!r}"
|
||||
|
||||
|
||||
def test_get_version_semver_format() -> None:
|
||||
"""get_version() should return a semver-like string X.Y.Z."""
|
||||
version = get_version()
|
||||
assert re.match(r"^\d+\.\d+\.\d+", version), f"Not semver: {version!r}"
|
||||
|
||||
|
||||
def test_get_version_fallback_when_toml_missing(tmp_path: Path) -> None:
|
||||
"""get_version() returns '0.0.0-unknown' when pyproject.toml and _version.py are both missing."""
|
||||
missing = tmp_path / "nonexistent.toml"
|
||||
with patch("src.constants._PYPROJECT_PATH", missing):
|
||||
result = get_version()
|
||||
# Either fallback _version.py exists (from previous run) or returns unknown
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
|
||||
def test_get_version_unknown_fallback(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_version() returns '0.0.0-unknown' when all sources are unavailable."""
|
||||
missing = tmp_path / "nonexistent.toml"
|
||||
monkeypatch.setattr("src.constants._PYPROJECT_PATH", missing)
|
||||
|
||||
# Patch _version import to also fail
|
||||
with patch("src.constants.Path.write_text", side_effect=OSError):
|
||||
with patch.dict("sys.modules", {"src._version": None}):
|
||||
result = get_version()
|
||||
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_debug_mode()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_debug_mode_returns_bool() -> None:
|
||||
"""get_debug_mode() should always return a bool."""
|
||||
assert isinstance(get_debug_mode(), bool)
|
||||
|
||||
|
||||
def test_get_debug_mode_true(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_debug_mode() returns True when ENV_DEBUG=true."""
|
||||
monkeypatch.setenv("ENV_DEBUG", "true")
|
||||
assert get_debug_mode() is True
|
||||
|
||||
|
||||
def test_get_debug_mode_true_variants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_debug_mode() accepts '1' and 'yes' as truthy values."""
|
||||
for value in ("1", "yes", "YES", "True", "TRUE"):
|
||||
monkeypatch.setenv("ENV_DEBUG", value)
|
||||
assert get_debug_mode() is True, f"Expected True for ENV_DEBUG={value!r}"
|
||||
|
||||
|
||||
def test_get_debug_mode_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_debug_mode() returns False when ENV_DEBUG=false."""
|
||||
monkeypatch.setenv("ENV_DEBUG", "false")
|
||||
assert get_debug_mode() is False
|
||||
|
||||
|
||||
def test_get_debug_mode_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""get_debug_mode() returns False when ENV_DEBUG is not set."""
|
||||
monkeypatch.delenv("ENV_DEBUG", raising=False)
|
||||
assert get_debug_mode() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_env_debug_is_bool() -> None:
|
||||
"""ENV_DEBUG should be a bool."""
|
||||
assert isinstance(ENV_DEBUG, bool)
|
||||
|
||||
|
||||
def test_app_version_is_string() -> None:
|
||||
"""APP_VERSION should be a string."""
|
||||
assert isinstance(APP_VERSION, str)
|
||||
|
||||
|
||||
def test_app_version_semver_format() -> None:
|
||||
"""APP_VERSION should follow semver format X.Y.Z."""
|
||||
assert re.match(r"^\d+\.\d+\.\d+", APP_VERSION), f"Not semver: {APP_VERSION!r}"
|
||||
def test_version_has_v_prefix() -> None:
|
||||
assert VERSION.startswith("v")
|
||||
|
||||
|
||||
def test_app_name_value() -> None:
|
||||
"""APP_NAME should be 'X4 SavEd'."""
|
||||
assert APP_NAME == "X4 SavEd"
|
||||
assert APP_NAME == "GOGUpdater"
|
||||
|
||||
|
||||
def test_app_title_contains_name_and_version() -> None:
|
||||
"""APP_TITLE should contain APP_NAME and APP_VERSION."""
|
||||
assert APP_NAME in APP_TITLE
|
||||
assert APP_VERSION in APP_TITLE
|
||||
def test_app_title_contains_version() -> None:
|
||||
assert VERSION in APP_TITLE
|
||||
|
||||
|
||||
def test_app_title_dev_suffix_when_debug(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""APP_TITLE ends with '-DEV' when ENV_DEBUG is True."""
|
||||
import importlib
|
||||
import src.constants as consts
|
||||
|
||||
monkeypatch.setenv("ENV_DEBUG", "true")
|
||||
monkeypatch.setattr(consts, "ENV_DEBUG", True)
|
||||
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if True else "")
|
||||
assert title.endswith("-DEV")
|
||||
def test_load_debug_returns_bool() -> None:
|
||||
assert isinstance(_load_debug(), bool)
|
||||
|
||||
|
||||
def test_app_title_no_dev_suffix_when_not_debug(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""APP_TITLE does not end with '-DEV' when ENV_DEBUG is False."""
|
||||
import src.constants as consts
|
||||
def test_load_debug_true_variants(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for value in ("true", "1", "yes", "YES", "True"):
|
||||
monkeypatch.setenv("ENV_DEBUG", value)
|
||||
assert _load_debug() is True, f"Expected True for ENV_DEBUG={value!r}"
|
||||
|
||||
monkeypatch.setattr(consts, "ENV_DEBUG", False)
|
||||
title = f"{consts.APP_NAME} v{consts.APP_VERSION}" + ("-DEV" if False else "")
|
||||
assert not title.endswith("-DEV")
|
||||
|
||||
def test_load_debug_false_when_unset(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("ENV_DEBUG", raising=False)
|
||||
assert _load_debug() is False
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for atomic file writes."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.fileutil import atomic_write_text
|
||||
|
||||
|
||||
def test_writes_content(tmp_path: Path) -> None:
|
||||
target = tmp_path / "out.json"
|
||||
atomic_write_text(target, '{"a": 1}')
|
||||
assert target.read_text(encoding="utf-8") == '{"a": 1}'
|
||||
|
||||
|
||||
def test_creates_parent_dirs(tmp_path: Path) -> None:
|
||||
target = tmp_path / "nested" / "deep" / "out.txt"
|
||||
atomic_write_text(target, "hi")
|
||||
assert target.read_text(encoding="utf-8") == "hi"
|
||||
|
||||
|
||||
def test_overwrites_existing(tmp_path: Path) -> None:
|
||||
target = tmp_path / "out.txt"
|
||||
target.write_text("old", encoding="utf-8")
|
||||
atomic_write_text(target, "new")
|
||||
assert target.read_text(encoding="utf-8") == "new"
|
||||
|
||||
|
||||
def test_leaves_no_temp_files_on_success(tmp_path: Path) -> None:
|
||||
target = tmp_path / "out.txt"
|
||||
atomic_write_text(target, "data")
|
||||
assert [p.name for p in tmp_path.iterdir()] == ["out.txt"]
|
||||
|
||||
|
||||
def test_original_intact_when_write_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
target = tmp_path / "out.txt"
|
||||
target.write_text("original", encoding="utf-8")
|
||||
|
||||
def boom(*args: object, **kwargs: object) -> None:
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr("os.replace", boom)
|
||||
with pytest.raises(OSError):
|
||||
atomic_write_text(target, "new")
|
||||
|
||||
# Original file is untouched and no temp file is left behind.
|
||||
assert target.read_text(encoding="utf-8") == "original"
|
||||
assert sorted(p.name for p in tmp_path.iterdir()) == ["out.txt"]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Tests for pure helpers in src.models."""
|
||||
|
||||
from src.models import language_folder_name, sanitize_folder_name
|
||||
|
||||
|
||||
class TestSanitizeFolderName:
|
||||
def test_plain_name_unchanged(self) -> None:
|
||||
assert sanitize_folder_name("Fallout 2") == "Fallout 2"
|
||||
|
||||
def test_colon_becomes_dash(self) -> None:
|
||||
assert sanitize_folder_name("Baldur's Gate: Dark Alliance") == "Baldur's Gate - Dark Alliance"
|
||||
|
||||
def test_strips_trademark_symbols(self) -> None:
|
||||
assert sanitize_folder_name("Game™ Title®") == "Game Title"
|
||||
|
||||
def test_removes_invalid_filesystem_chars(self) -> None:
|
||||
# ':' becomes ' - ' first, then <>"/\|?* are stripped.
|
||||
assert sanitize_folder_name('A<B>C:D"E/F\\G|H?I*J') == "ABC - DEFGHIJ"
|
||||
|
||||
def test_collapses_multiple_spaces(self) -> None:
|
||||
assert sanitize_folder_name("A B") == "A B"
|
||||
|
||||
def test_collapses_adjacent_dashes_from_colons(self) -> None:
|
||||
assert sanitize_folder_name("A: : B") == "A - B"
|
||||
|
||||
def test_strips_leading_trailing_dots_and_space(self) -> None:
|
||||
assert sanitize_folder_name(" .Title. ") == "Title"
|
||||
|
||||
def test_idempotent(self) -> None:
|
||||
once = sanitize_folder_name("Witcher 3: Wild Hunt™")
|
||||
assert sanitize_folder_name(once) == once
|
||||
|
||||
|
||||
class TestLanguageFolderName:
|
||||
def test_known_code(self) -> None:
|
||||
assert language_folder_name("cs") == "Czech"
|
||||
|
||||
def test_unknown_code_passthrough(self) -> None:
|
||||
assert language_folder_name("xx") == "xx"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for prune version-selection logic (MetadataStore._select_versions_to_keep)."""
|
||||
|
||||
from src.config import MetadataStore
|
||||
from src.models import DownloadedInstaller, GameRecord, InstallerType, PruneStrategy
|
||||
|
||||
|
||||
def _record(version_years: dict[str, int]) -> GameRecord:
|
||||
"""Build a GameRecord with one installer per (version, year)."""
|
||||
installers = [
|
||||
DownloadedInstaller(
|
||||
filename=f"setup_{v}.exe",
|
||||
size=1,
|
||||
version=v,
|
||||
language="en",
|
||||
installer_type=InstallerType.GAME,
|
||||
downloaded_at=f"{year}-01-01T00:00:00",
|
||||
)
|
||||
for v, year in version_years.items()
|
||||
]
|
||||
return GameRecord(game_id="1", name="Game", installers=installers)
|
||||
|
||||
|
||||
VERSION_YEARS = {"1": 2020, "2": 2020, "3": 2021, "4": 2022, "5": 2022}
|
||||
ALL = ["1", "2", "3", "4", "5"]
|
||||
|
||||
|
||||
def _keep(strategy: PruneStrategy, keep_latest: int = 1) -> set[str]:
|
||||
record = _record(VERSION_YEARS)
|
||||
return MetadataStore._select_versions_to_keep(record, ALL, keep_latest, strategy)
|
||||
|
||||
|
||||
def test_returns_all_when_fewer_than_keep() -> None:
|
||||
record = _record({"1": 2020})
|
||||
assert MetadataStore._select_versions_to_keep(record, ["1"], 1, PruneStrategy.LATEST_N) == {"1"}
|
||||
|
||||
|
||||
def test_latest_n_keeps_only_recent() -> None:
|
||||
assert _keep(PruneStrategy.LATEST_N, keep_latest=1) == {"5"}
|
||||
assert _keep(PruneStrategy.LATEST_N, keep_latest=2) == {"4", "5"}
|
||||
|
||||
|
||||
def test_latest_n_oldest_adds_first() -> None:
|
||||
assert _keep(PruneStrategy.LATEST_N_OLDEST, keep_latest=1) == {"1", "5"}
|
||||
|
||||
|
||||
def test_yearly_keeps_one_per_year_plus_latest() -> None:
|
||||
assert _keep(PruneStrategy.YEARLY, keep_latest=1) == {"2", "3", "5"}
|
||||
|
||||
|
||||
def test_latest_n_yearly_oldest_combines_all() -> None:
|
||||
assert _keep(PruneStrategy.LATEST_N_YEARLY_OLDEST, keep_latest=1) == {"1", "2", "3", "5"}
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Tests for version comparison logic."""
|
||||
|
||||
from src.version_compare import CompareResult, compare_versions, normalize_version
|
||||
|
||||
|
||||
class TestNormalizeVersion:
|
||||
def test_strips_gog_suffix(self) -> None:
|
||||
assert normalize_version("2.2 (gog-3)") == "2.2"
|
||||
|
||||
def test_strips_galaxy_suffix(self) -> None:
|
||||
assert normalize_version("1.0 (Galaxy)") == "1.0"
|
||||
|
||||
def test_plain_unchanged(self) -> None:
|
||||
assert normalize_version("1.63") == "1.63"
|
||||
|
||||
|
||||
class TestCompareVersions:
|
||||
def test_equal_exact(self) -> None:
|
||||
assert compare_versions("1.63", "1.63") == CompareResult.EQUAL
|
||||
|
||||
def test_equal_after_normalize(self) -> None:
|
||||
assert compare_versions("2.2", "2.2 (gog-3)") == CompareResult.EQUAL
|
||||
|
||||
def test_older(self) -> None:
|
||||
assert compare_versions("1.52", "1.63") == CompareResult.OLDER
|
||||
|
||||
def test_newer(self) -> None:
|
||||
assert compare_versions("2.0", "1.9") == CompareResult.NEWER
|
||||
|
||||
def test_different_part_counts_padded(self) -> None:
|
||||
assert compare_versions("1.0", "1.0.1") == CompareResult.OLDER
|
||||
assert compare_versions("1.0.0", "1.0") == CompareResult.EQUAL
|
||||
|
||||
def test_empty_is_ambiguous(self) -> None:
|
||||
assert compare_versions("", "1.0") == CompareResult.AMBIGUOUS
|
||||
assert compare_versions("1.0", "") == CompareResult.AMBIGUOUS
|
||||
|
||||
def test_non_numeric_is_ambiguous(self) -> None:
|
||||
assert compare_versions("alpha", "beta") == CompareResult.AMBIGUOUS
|
||||
|
||||
def test_numeric_vs_nonnumeric_is_ambiguous(self) -> None:
|
||||
# "2.2(gog-3)" normalizes to "2.2" (numeric), "2.3a" is not numeric
|
||||
assert compare_versions("2.2 (gog-3)", "2.3a") == CompareResult.AMBIGUOUS
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Tests for background worker threading helpers."""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PySide6.QtCore import QObject # noqa: E402
|
||||
from PySide6.QtWidgets import QApplication # noqa: E402
|
||||
|
||||
from src.ui.workers import FetchWorker, run_on_thread # noqa: E402
|
||||
|
||||
|
||||
def _app() -> QApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def _spin_until(app: QApplication, predicate, timeout: float = 5.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while not predicate() and time.time() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def test_worker_runs_when_only_local_reference() -> None:
|
||||
"""Regression: a worker created in a local scope must still run.
|
||||
|
||||
run_on_thread must keep the worker alive; otherwise it is garbage-collected
|
||||
and its `run` slot is never invoked (the 'refresh does nothing' bug).
|
||||
"""
|
||||
app = _app()
|
||||
owner = QObject()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def start() -> None:
|
||||
worker = FetchWorker(lambda progress: 42)
|
||||
worker.result.connect(lambda r: captured.update(result=r))
|
||||
run_on_thread(owner, worker)
|
||||
# `worker` goes out of scope here
|
||||
|
||||
start()
|
||||
_spin_until(app, lambda: "result" in captured)
|
||||
assert captured.get("result") == 42
|
||||
|
||||
|
||||
def test_progress_and_cleanup() -> None:
|
||||
app = _app()
|
||||
owner = QObject()
|
||||
progress: list[tuple[int, int]] = []
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fn(report) -> str:
|
||||
report(1, 2)
|
||||
report(2, 2)
|
||||
return "ok"
|
||||
|
||||
worker = FetchWorker(fn)
|
||||
worker.progress.connect(lambda d, t: progress.append((d, t)))
|
||||
worker.result.connect(lambda r: captured.update(result=r))
|
||||
run_on_thread(owner, worker)
|
||||
|
||||
_spin_until(app, lambda: "result" in captured)
|
||||
# let the thread.finished cleanup run
|
||||
_spin_until(app, lambda: not owner._active_threads)
|
||||
|
||||
assert captured.get("result") == "ok"
|
||||
assert (2, 2) in progress
|
||||
assert owner._active_threads == []
|
||||
Reference in New Issue
Block a user