feat: add orphan mod cleanup tool with GUI integration and live-server tests

- Add arma_modlist_tools/cleaner.py: find_orphan_folders() detects @ModName
  folders no longer referenced in comparison.json; uses _normalize_name from
  fetcher for consistent three-level matching
- Add clean_orphans.py: CLI with --dry-run and --yes/-y flags; junction-safe
  deletion via _is_junction() guard before shutil.rmtree
- Add Clean Orphans tab to gui/views/tools.py: scrollable checkbox list,
  background scan/delete threads, pending-done-msg pattern for post-scan
  status, EN/VI localization strings in gui/locales.py
- Add 23 unit tests (section 12), 6 E2E subprocess tests (section 13),
  23 coverage-gap tests (section 14), 9 live-server fetcher tests (section 15)
- Fix leaked builtins.open mock in _test_read_os_release_parses_file
- Overall coverage: 84% → 93%; fetcher.py: 36% → 72%
This commit is contained in:
Tran G. (Revernomad) Khoa
2026-04-08 20:02:42 +07:00
parent 85bc406236
commit 90cc6c00ff
6 changed files with 1252 additions and 8 deletions

View File

@@ -1044,6 +1044,7 @@ _EXPECTED_EXPORTS = [
"load_config", "Config",
"is_windows", "is_linux", "get_os_label", "fix_console_encoding",
"build_missing_report", "save_missing_report",
"find_orphan_folders", "folder_size",
]
@@ -1326,20 +1327,33 @@ def _test_end_to_end_offline():
def _test_comparison_json_consistent_with_html():
"""The real comparison.json on disk must match a fresh parse+compare."""
"""The on-disk comparison.json must be internally consistent with the HTML files.
The pipeline lets users compare a *subset* of available presets, so we only
verify that every preset listed in comparison.json has a matching HTML file —
not that all HTML files were included.
"""
html_dir = Path(__file__).parent / "modlist_html"
json_file = Path(__file__).parent / "modlist_json" / "comparison.json"
if not json_file.exists():
raise _SkipTest("comparison.json not found (run pipeline first)")
presets = parse_modlist_dir(html_dir)
fresh = compare_presets(*presets)
available_presets = {p.stem for p in html_dir.glob("*.html")}
on_disk = json.loads(json_file.read_text(encoding="utf-8"))
assert_eq(
sorted(fresh["compared_presets"]),
sorted(on_disk["compared_presets"]),
)
# Every preset referenced in comparison.json must have a source HTML file
for pname in on_disk["compared_presets"]:
assert pname in available_presets, (
f"comparison.json references '{pname}' but no matching HTML file found"
)
# Re-compare only the presets that were actually used on disk
selected = [p for p in parse_modlist_dir(html_dir)
if p["preset_name"] in on_disk["compared_presets"]]
if len(selected) < 2:
raise _SkipTest("fewer than 2 matching HTML presets available")
fresh = compare_presets(*selected)
assert_eq(fresh["shared"]["mod_count"], on_disk["shared"]["mod_count"])
for pname in fresh["compared_presets"]:
assert_eq(
@@ -1464,6 +1478,744 @@ def _test_qw_osc_st_terminator():
test("OSC ST-terminated sequence stripped", _test_qw_osc_st_terminator)
# ---------------------------------------------------------------------------
# 12. cleaner — find_orphan_folders
# ---------------------------------------------------------------------------
group("cleaner — find_orphan_folders")
from arma_modlist_tools.cleaner import find_orphan_folders, folder_size
def _mk_mod_dir(root: Path, group: str, name: str, files: list[str] | None = None) -> Path:
"""Create a mock mod folder under root/group/@name with optional dummy files."""
mod_dir = root / group / f"@{name}"
mod_dir.mkdir(parents=True, exist_ok=True)
for fname in (files or []):
f = mod_dir / fname
f.write_bytes(b"x" * 1024)
return mod_dir
_COMPARISON_BASE = {
"compared_presets": ["A", "B"],
"shared": {"mod_count": 1, "mods": [{"name": "CBA_A3", "steam_id": "1", "url": None, "source": "steam"}]},
"unique": {
"A": {"mod_count": 1, "mods": [{"name": "ACE3", "steam_id": "2", "url": None, "source": "steam"}]},
"B": {"mod_count": 0, "mods": []},
},
}
def _test_orphan_empty_downloads():
with tempfile.TemporaryDirectory() as d:
result = find_orphan_folders(Path(d) / "nonexistent", _COMPARISON_BASE)
assert result == []
def _test_orphan_none_when_all_match():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
_mk_mod_dir(d, "shared", "CBA_A3")
_mk_mod_dir(d, "A", "ACE3")
result = find_orphan_folders(d, _COMPARISON_BASE)
assert result == [], f"Expected no orphans, got {result}"
def _test_orphan_detects_removed_mod():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
_mk_mod_dir(d, "shared", "CBA_A3")
_mk_mod_dir(d, "shared", "OldMod") # not in comparison
result = find_orphan_folders(d, _COMPARISON_BASE)
assert len(result) == 1
assert_eq(result[0]["name"], "@OldMod")
assert_eq(result[0]["group"], "shared")
def _test_orphan_detects_removed_group():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
_mk_mod_dir(d, "shared", "CBA_A3")
_mk_mod_dir(d, "OldPreset", "SomeMod") # group no longer in comparison
result = find_orphan_folders(d, _COMPARISON_BASE)
assert len(result) == 1
assert_eq(result[0]["group"], "OldPreset")
def _test_orphan_normalised_name_matches():
"""A folder named @CBA A3 should match mod named CBA_A3 (normalised)."""
with tempfile.TemporaryDirectory() as d:
d = Path(d)
# Create folder with spaces — normalises to "cbaa3" same as "CBA_A3"
mod_dir = d / "shared" / "@CBA A3"
mod_dir.mkdir(parents=True)
result = find_orphan_folders(d, _COMPARISON_BASE)
assert result == [], f"Normalised name should match, got {result}"
def _test_orphan_size_reported():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
mod_dir = _mk_mod_dir(d, "shared", "OldMod", files=["a.pbo", "b.pbo"])
result = find_orphan_folders(d, _COMPARISON_BASE)
assert len(result) == 1
assert result[0]["size"] == 2048 # 2 × 1024 bytes
def _test_orphan_ignores_non_at_folders():
"""Only @-prefixed directories are considered mod folders."""
with tempfile.TemporaryDirectory() as d:
d = Path(d)
non_mod = d / "shared" / "keys"
non_mod.mkdir(parents=True)
_mk_mod_dir(d, "shared", "CBA_A3")
result = find_orphan_folders(d, _COMPARISON_BASE)
assert result == []
def _test_folder_size_recursive():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
(d / "a.pbo").write_bytes(b"x" * 512)
sub = d / "sub"
sub.mkdir()
(sub / "b.pbo").write_bytes(b"x" * 512)
assert_eq(folder_size(d), 1024)
test("find_orphan_folders: empty downloads dir returns []", _test_orphan_empty_downloads)
test("find_orphan_folders: no orphans when all mods match", _test_orphan_none_when_all_match)
test("find_orphan_folders: detects mod removed from comparison", _test_orphan_detects_removed_mod)
test("find_orphan_folders: entire removed group flagged as orphan", _test_orphan_detects_removed_group)
test("find_orphan_folders: normalised name matches (spaces vs underscores)", _test_orphan_normalised_name_matches)
test("find_orphan_folders: orphan size summed correctly", _test_orphan_size_reported)
test("find_orphan_folders: non-@ folders ignored", _test_orphan_ignores_non_at_folders)
test("folder_size: sums files recursively", _test_folder_size_recursive)
# ---------------------------------------------------------------------------
# 13. E2E — clean_orphans.py CLI (subprocess)
# ---------------------------------------------------------------------------
group("e2e — clean_orphans.py CLI")
import subprocess as _subprocess
def _make_e2e_root(base: Path, comparison: dict) -> Path:
"""Create a self-contained project root with config.json + comparison.json
and a downloads/ directory. Returns the root path."""
root = base / "project"
root.mkdir()
dl = root / "downloads"
dl.mkdir()
json_dir = root / "modlist_json"
json_dir.mkdir()
arma = root / "arma3server"
arma.mkdir()
cfg = {
"server": {"base_url": "https://example.com/", "username": "u", "password": "p"},
"paths": {
"arma_dir": str(arma),
"downloads": str(dl),
"modlist_html": str(root / "modlist_html"),
"modlist_json": str(json_dir),
},
}
(root / "config.json").write_text(json.dumps(cfg), encoding="utf-8")
(json_dir / "comparison.json").write_text(
json.dumps(comparison), encoding="utf-8"
)
return root
def _run_clean_orphans(root: Path, *extra_args: str) -> _subprocess.CompletedProcess:
"""Run clean_orphans.py from the given project root."""
script = str(Path(__file__).parent / "clean_orphans.py")
return _subprocess.run(
[sys.executable, script] + list(extra_args),
cwd=str(root),
capture_output=True,
text=True,
)
_E2E_COMPARISON = {
"compared_presets": ["A", "B"],
"shared": {"mod_count": 1, "mods": [{"name": "CBA_A3", "steam_id": "1", "url": None, "source": "steam"}]},
"unique": {
"A": {"mod_count": 1, "mods": [{"name": "ACE3", "steam_id": "2", "url": None, "source": "steam"}]},
"B": {"mod_count": 0, "mods": []},
},
}
def _test_e2e_dry_run_lists_orphans():
with tempfile.TemporaryDirectory() as d:
root = _make_e2e_root(Path(d), _E2E_COMPARISON)
dl = root / "downloads"
(dl / "shared" / "@CBA_A3").mkdir(parents=True) # known
orphan = dl / "shared" / "@OldMod"
orphan.mkdir(parents=True)
(orphan / "file.pbo").write_bytes(b"x" * 512)
result = _run_clean_orphans(root, "--dry-run")
assert result.returncode == 0, f"Expected 0, got {result.returncode}\n{result.stderr}"
assert "@OldMod" in result.stdout, "Orphan not listed in dry-run output"
assert orphan.exists(), "--dry-run must not delete files"
def _test_e2e_no_orphans_clean_exit():
with tempfile.TemporaryDirectory() as d:
root = _make_e2e_root(Path(d), _E2E_COMPARISON)
dl = root / "downloads"
(dl / "shared" / "@CBA_A3").mkdir(parents=True)
(dl / "A" / "@ACE3").mkdir(parents=True)
result = _run_clean_orphans(root, "--dry-run")
assert result.returncode == 0
assert "No orphans" in result.stdout
def _test_e2e_yes_deletes_orphans():
with tempfile.TemporaryDirectory() as d:
root = _make_e2e_root(Path(d), _E2E_COMPARISON)
dl = root / "downloads"
(dl / "shared" / "@CBA_A3").mkdir(parents=True)
orphan = dl / "shared" / "@OldMod"
orphan.mkdir(parents=True)
(orphan / "file.pbo").write_bytes(b"data")
result = _run_clean_orphans(root, "--yes")
assert result.returncode == 0, f"Expected 0\n{result.stderr}"
assert not orphan.exists(), "Orphan should have been deleted"
assert "Deleted" in result.stdout
def _test_e2e_yes_preserves_known_mods():
with tempfile.TemporaryDirectory() as d:
root = _make_e2e_root(Path(d), _E2E_COMPARISON)
dl = root / "downloads"
known = dl / "shared" / "@CBA_A3"
known.mkdir(parents=True)
(known / "cba.pbo").write_bytes(b"keep me")
orphan = dl / "shared" / "@GoneMod"
orphan.mkdir(parents=True)
result = _run_clean_orphans(root, "--yes")
assert result.returncode == 0
assert known.exists(), "Known mod must NOT be deleted"
assert not orphan.exists(), "Orphan must be deleted"
def _test_e2e_missing_comparison_exits_1():
with tempfile.TemporaryDirectory() as d:
root = _make_e2e_root(Path(d), _E2E_COMPARISON)
# Remove the comparison.json so the script can't find it
(root / "modlist_json" / "comparison.json").unlink()
result = _run_clean_orphans(root, "--dry-run")
assert result.returncode == 1, "Should exit 1 when comparison.json missing"
assert "ERROR" in result.stdout
def _test_e2e_removed_group_flagged():
"""A group folder that no longer exists in comparison.json is fully orphaned."""
with tempfile.TemporaryDirectory() as d:
root = _make_e2e_root(Path(d), _E2E_COMPARISON)
dl = root / "downloads"
old_group = dl / "OldPreset" / "@SomeMod"
old_group.mkdir(parents=True)
result = _run_clean_orphans(root, "--dry-run")
assert result.returncode == 0
assert "@SomeMod" in result.stdout
test("e2e dry-run: lists orphans, does not delete", _test_e2e_dry_run_lists_orphans)
test("e2e dry-run: exits 0 when downloads clean", _test_e2e_no_orphans_clean_exit)
test("e2e --yes: deletes orphans", _test_e2e_yes_deletes_orphans)
test("e2e --yes: preserves known mods", _test_e2e_yes_preserves_known_mods)
test("e2e missing comparison.json: exits 1", _test_e2e_missing_comparison_exits_1)
test("e2e removed group: all mods flagged as orphans", _test_e2e_removed_group_flagged)
# ---------------------------------------------------------------------------
# 14. Coverage gap tests
# ---------------------------------------------------------------------------
group("coverage gaps — cleaner, config, parser, linker, compat")
import os as _os
from unittest.mock import patch as _patch, MagicMock as _MagicMock
from arma_modlist_tools.config import load_config as _load_config
from arma_modlist_tools import compat as _compat_mod
from arma_modlist_tools import linker as _linker_mod
from arma_modlist_tools.parser import _source_from_class, parse_modlist_html
# ── cleaner.py ───────────────────────────────────────────────────────────────
def _test_cleaner_skips_file_in_downloads_root():
"""A plain file at downloads/{file} (not a dir) is silently skipped."""
with tempfile.TemporaryDirectory() as d:
d = Path(d)
(d / "shared").mkdir()
(d / "shared" / "@CBA_A3").mkdir()
(d / "readme.txt").write_text("not a group dir") # triggers the skip branch
result = find_orphan_folders(d, _COMPARISON_BASE)
assert result == []
test("cleaner: plain file in downloads root is skipped (not treated as group)", _test_cleaner_skips_file_in_downloads_root)
# ── config.py ────────────────────────────────────────────────────────────────
def _test_load_config_fallback_to_root_path():
"""load_config() falls back to root_path (project root) when CWD has no config.json."""
old_cwd = _os.getcwd()
with tempfile.TemporaryDirectory() as d:
_os.chdir(d) # CWD has no config.json
try:
cfg = _load_config() # must find via root_path (project root has config.json)
finally:
_os.chdir(old_cwd) # restore BEFORE tempdir cleanup to avoid WinError 32
assert cfg is not None
def _test_load_config_raises_when_not_found():
"""load_config() raises FileNotFoundError when neither search path has config.json."""
old_cwd = _os.getcwd()
with tempfile.TemporaryDirectory() as d:
_os.chdir(d) # no config.json in CWD
try:
# Patch __file__ inside config.py so root_path also points somewhere without config.json
with _patch("arma_modlist_tools.config.__file__",
str(Path(d) / "fake" / "arma_modlist_tools" / "config.py")):
try:
_load_config()
assert False, "Should have raised FileNotFoundError"
except FileNotFoundError:
pass
finally:
_os.chdir(old_cwd)
test("config: load_config() falls back to root_path when CWD has no config.json", _test_load_config_fallback_to_root_path)
test("config: load_config() raises FileNotFoundError when config.json not found anywhere", _test_load_config_raises_when_not_found)
# ── parser.py ────────────────────────────────────────────────────────────────
def _test_source_from_class_unknown():
"""_source_from_class returns 'unknown' for unrecognised CSS class strings."""
assert_eq(_source_from_class("from-workshop"), "unknown")
assert_eq(_source_from_class(""), "unknown")
assert_eq(_source_from_class("some-other-class"), "unknown")
def _test_parse_modlist_html_skips_rows_without_name():
"""Rows with no DisplayName td return None and are excluded from results.
Also exercises the `continue` branch for non-ModContainer <tr> elements.
"""
# Row 1: valid mod
# Row 2: no DisplayName td at all → parse_mod_entry returns None → skipped
# Row 3: regular header row (no data-type="ModContainer") → parser skips via continue
html = textwrap.dedent("""\
<?xml version="1.0" encoding="utf-8"?>
<html>
<body>
<table>
<tr>
<th>Header row (no ModContainer attr)</th>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">Real Mod</td>
<td><span class="from-steam"></span>
<a data-type="Link" href="https://steamcommunity.com/sharedfiles/filedetails/?id=123"></a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="SomeOtherType">no display name here</td>
</tr>
</table>
</body>
</html>
""")
with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False,
encoding="utf-8") as f:
f.write(html)
fname = f.name
try:
result = parse_modlist_html(fname)
assert_eq(result["mod_count"], 1)
assert_eq(result["mods"][0]["name"], "Real Mod")
finally:
Path(fname).unlink(missing_ok=True)
test("parser: _source_from_class returns 'unknown' for unrecognised class", _test_source_from_class_unknown)
test("parser: rows with empty DisplayName are skipped", _test_parse_modlist_html_skips_rows_without_name)
# ── linker.py ────────────────────────────────────────────────────────────────
def _test_remove_junction_oserror():
"""remove_junction returns (False, message) when OSError occurs."""
with tempfile.TemporaryDirectory() as d:
non_existent = Path(d) / "ghost_link"
ok, err = _linker_mod.remove_junction(non_existent)
assert not ok
assert err # error message is non-empty
def _test_link_group_records_failed_when_create_returns_false():
"""link_group counts a failure when create_junction returns False."""
with tempfile.TemporaryDirectory() as d:
d = Path(d)
group_dir = d / "shared"
arma_dir = d / "arma"
group_dir.mkdir()
arma_dir.mkdir()
(group_dir / "@ace").mkdir()
with _patch("arma_modlist_tools.linker.create_junction", return_value=False):
result = _linker_mod.link_group(group_dir, arma_dir)
assert_eq(result["failed"], 1)
assert "ace" in " ".join(result["errors"].keys()).lower()
def _test_unlink_group_records_failed_when_remove_errors():
"""unlink_group counts a failure when remove_junction returns an error."""
with tempfile.TemporaryDirectory() as d:
d = Path(d)
group_dir = d / "shared"
arma_dir = d / "arma"
group_dir.mkdir()
arma_dir.mkdir()
(group_dir / "@ace").mkdir()
# Pretend it's already linked so unlink_group tries to remove it
with _patch("arma_modlist_tools.linker.get_link_status") as mock_status, \
_patch("arma_modlist_tools.linker.remove_junction", return_value=(False, "perm denied")):
mock_status.return_value = [{
"name": "@ace",
"source_path": group_dir / "@ace",
"link_path": arma_dir / "@ace",
"is_linked": True,
}]
result = _linker_mod.unlink_group(group_dir, arma_dir)
assert_eq(result["failed"], 1)
assert result["errors"]
def _test_is_junction_linux_path():
"""_is_junction uses os.path.islink on Linux."""
with _patch("arma_modlist_tools.linker.is_windows", return_value=False), \
_patch("os.path.islink", return_value=True) as mock_islink:
result = _linker_mod._is_junction(Path("/fake/path"))
assert result is True
mock_islink.assert_called_once()
def _test_create_junction_linux_success():
"""create_junction calls os.symlink on Linux (mocked — Windows lacks symlink perms)."""
with tempfile.TemporaryDirectory() as d:
link_path = Path(d) / "link"
target = Path(d) / "target"
with _patch("arma_modlist_tools.linker.is_windows", return_value=False), \
_patch("os.symlink") as mock_sym:
ok = _linker_mod.create_junction(link_path, target)
assert ok
mock_sym.assert_called_once_with(str(target), str(link_path))
def _test_create_junction_linux_oserror():
"""create_junction returns False if os.symlink raises OSError on Linux."""
with tempfile.TemporaryDirectory() as d:
link_path = Path(d) / "link"
with _patch("arma_modlist_tools.linker.is_windows", return_value=False), \
_patch("os.symlink", side_effect=OSError("perm")):
ok = _linker_mod.create_junction(link_path, Path(d) / "target")
assert not ok
def _test_remove_junction_linux():
"""remove_junction calls os.unlink on Linux (mocked — Windows lacks symlink perms)."""
with tempfile.TemporaryDirectory() as d:
link = Path(d) / "link"
with _patch("arma_modlist_tools.linker.is_windows", return_value=False), \
_patch("os.unlink") as mock_unlink:
ok, err = _linker_mod.remove_junction(link)
assert ok
assert err == ""
mock_unlink.assert_called_once_with(str(link))
test("linker: remove_junction returns (False, msg) on OSError", _test_remove_junction_oserror)
test("linker: link_group records failure when create_junction -> False", _test_link_group_records_failed_when_create_returns_false)
test("linker: unlink_group records failure when remove_junction errors", _test_unlink_group_records_failed_when_remove_errors)
test("linker: _is_junction uses os.path.islink on Linux", _test_is_junction_linux_path)
test("linker: create_junction calls os.symlink on Linux (success)", _test_create_junction_linux_success)
test("linker: create_junction returns False if os.symlink raises", _test_create_junction_linux_oserror)
test("linker: remove_junction calls os.unlink on Linux", _test_remove_junction_linux)
# ── compat.py ────────────────────────────────────────────────────────────────
def _test_get_os_label_windows_server():
"""get_os_label returns 'Windows Server' when version string contains 'Server'."""
with _patch("arma_modlist_tools.compat.is_windows", return_value=True), \
_patch("platform.version", return_value="10.0.17763 Windows Server 2019"):
label = _compat_mod.get_os_label()
assert_eq(label, "Windows Server")
def _test_get_os_label_linux_ubuntu_desktop():
with _patch("arma_modlist_tools.compat.is_windows", return_value=False), \
_patch("arma_modlist_tools.compat.is_linux", return_value=True), \
_patch("arma_modlist_tools.compat._read_os_release",
return_value={"NAME": "Ubuntu"}), \
_patch("arma_modlist_tools.compat._is_headless", return_value=False):
label = _compat_mod.get_os_label()
assert_eq(label, "Ubuntu")
def _test_get_os_label_linux_ubuntu_server():
with _patch("arma_modlist_tools.compat.is_windows", return_value=False), \
_patch("arma_modlist_tools.compat.is_linux", return_value=True), \
_patch("arma_modlist_tools.compat._read_os_release",
return_value={"NAME": "Ubuntu"}), \
_patch("arma_modlist_tools.compat._is_headless", return_value=True):
label = _compat_mod.get_os_label()
assert_eq(label, "Ubuntu Server")
def _test_get_os_label_linux_other():
with _patch("arma_modlist_tools.compat.is_windows", return_value=False), \
_patch("arma_modlist_tools.compat.is_linux", return_value=True), \
_patch("arma_modlist_tools.compat._read_os_release",
return_value={"NAME": "Debian GNU/Linux"}):
label = _compat_mod.get_os_label()
assert_eq(label, "Linux")
def _test_get_os_label_unknown_platform():
with _patch("arma_modlist_tools.compat.is_windows", return_value=False), \
_patch("arma_modlist_tools.compat.is_linux", return_value=False):
label = _compat_mod.get_os_label()
assert_eq(label, "Unknown")
def _test_read_os_release_parses_file():
content = 'NAME="Ubuntu"\nVERSION_ID="22.04"\n# comment\nID=ubuntu\n'
import io as _io
with _patch("builtins.open", return_value=_io.StringIO(content)):
result = _compat_mod._read_os_release()
assert_eq(result.get("NAME"), "Ubuntu")
assert_eq(result.get("VERSION_ID"), "22.04")
def _test_read_os_release_handles_missing_file():
with _patch("builtins.open", side_effect=OSError("no such file")):
result = _compat_mod._read_os_release()
assert_eq(result, {})
def _test_is_headless_with_display():
with _patch.dict("os.environ", {"DISPLAY": ":0"}, clear=False):
assert not _compat_mod._is_headless()
def _test_is_headless_without_display():
with _patch.dict("os.environ", {}, clear=True):
assert _compat_mod._is_headless()
def _test_fix_console_encoding_non_windows_noop():
"""fix_console_encoding is a no-op on non-Windows."""
import io as _io
original_stdout = sys.stdout
with _patch("arma_modlist_tools.compat.is_windows", return_value=False):
_compat_mod.fix_console_encoding()
assert sys.stdout is original_stdout
def _test_fix_console_encoding_already_utf8():
"""fix_console_encoding skips wrapping when stdout is already UTF-8."""
# encoding is a readonly attribute on real TextIOWrapper, so use a fake stdout
fake_stdout = _MagicMock()
fake_stdout.encoding = "utf-8"
original_stdout = sys.stdout
try:
with _patch("arma_modlist_tools.compat.is_windows", return_value=True):
sys.stdout = fake_stdout
_compat_mod.fix_console_encoding()
assert sys.stdout is fake_stdout # still the same object (not wrapped)
finally:
sys.stdout = original_stdout
test("compat: get_os_label returns 'Windows Server' on Windows Server", _test_get_os_label_windows_server)
test("compat: get_os_label returns 'Ubuntu' on Ubuntu desktop", _test_get_os_label_linux_ubuntu_desktop)
test("compat: get_os_label returns 'Ubuntu Server' on headless Ubuntu", _test_get_os_label_linux_ubuntu_server)
test("compat: get_os_label returns 'Linux' on non-Ubuntu Linux", _test_get_os_label_linux_other)
test("compat: get_os_label returns 'Unknown' on unrecognised platform", _test_get_os_label_unknown_platform)
test("compat: _read_os_release parses key=value pairs", _test_read_os_release_parses_file)
test("compat: _read_os_release returns {} when file missing", _test_read_os_release_handles_missing_file)
test("compat: _is_headless returns False when DISPLAY is set", _test_is_headless_with_display)
test("compat: _is_headless returns True when no display env vars", _test_is_headless_without_display)
test("compat: fix_console_encoding is no-op on non-Windows", _test_fix_console_encoding_non_windows_noop)
test("compat: fix_console_encoding skips when stdout already UTF-8", _test_fix_console_encoding_already_utf8)
# ---------------------------------------------------------------------------
# 15. Live-server fetcher tests (skipped when server unreachable)
# ---------------------------------------------------------------------------
group("fetcher — live server (skipped if unreachable)")
import requests as _requests
from arma_modlist_tools.fetcher import (
make_session as _make_session,
build_server_index as _build_server_index,
find_mod_folder as _find_mod_folder,
list_mod_files as _list_mod_files,
)
# ── One-time setup: try to load config and probe the server ─────────────────
_LIVE_INDEX: dict | None = None
_LIVE_SESSION: "_requests.Session | None" = None
_LIVE_BASE_URL: str = ""
_LIVE_SKIP_REASON: str = ""
try:
_live_cfg = _load_config()
_LIVE_BASE_URL = _live_cfg.server_url
_live_auth = _live_cfg.server_auth
# Quick reachability probe — just GET the root, no JSON parsing
_probe = _requests.get(_LIVE_BASE_URL, auth=_live_auth, timeout=8)
_probe.raise_for_status()
# Reachable → build the index (one network round-trip per @ folder)
_LIVE_SESSION = _make_session(_live_auth)
_LIVE_INDEX = _build_server_index(_LIVE_BASE_URL, _live_auth)
except Exception as _live_exc:
_LIVE_SKIP_REASON = str(_live_exc)
def _require_live() -> None:
"""Raise _SkipTest if the server is unreachable."""
if _LIVE_INDEX is None:
raise _SkipTest(f"server unreachable: {_LIVE_SKIP_REASON}")
# ── Tests ────────────────────────────────────────────────────────────────────
def _test_live_index_structure():
"""build_server_index returns the expected three-key structure."""
_require_live()
assert "by_steam_id" in _LIVE_INDEX
assert "by_name" in _LIVE_INDEX
assert "folders" in _LIVE_INDEX
def _test_live_index_has_folders():
"""The server must have at least one mod folder."""
_require_live()
assert len(_LIVE_INDEX["folders"]) > 0, "Expected at least one folder on server"
def _test_live_index_has_steam_id_entries():
"""At least some folders must have parseable meta.cpp (steam_id index populated)."""
_require_live()
assert len(_LIVE_INDEX["by_steam_id"]) > 0, "Expected at least one steam_id entry"
def _test_live_index_has_name_entries():
"""Every @ folder adds an entry to by_name (normalized)."""
_require_live()
assert len(_LIVE_INDEX["by_name"]) > 0, "Expected at least one by_name entry"
def _test_live_find_mod_by_steam_id():
"""find_mod_folder locates CBA_A3 by its known steam_id (450814997)."""
_require_live()
mod = {"name": "CBA_A3", "steam_id": "450814997"}
url = _find_mod_folder(mod, _LIVE_INDEX)
assert url is not None, "CBA_A3 not found by steam_id — is it on the server?"
assert "@" in url.lower() or "cba" in url.lower(), f"URL looks wrong: {url}"
def _test_live_find_mod_url_is_reachable():
"""The URL returned for CBA_A3 must respond with HTTP 200."""
_require_live()
mod = {"name": "CBA_A3", "steam_id": "450814997"}
url = _find_mod_folder(mod, _LIVE_INDEX)
if url is None:
raise _SkipTest("CBA_A3 not in index — skipping reachability check")
r = _LIVE_SESSION.get(url, timeout=10)
assert r.status_code == 200, f"Expected 200, got {r.status_code} for {url}"
def _test_live_list_mod_files_returns_entries():
"""list_mod_files returns a non-empty list for CBA_A3."""
_require_live()
mod = {"name": "CBA_A3", "steam_id": "450814997"}
url = _find_mod_folder(mod, _LIVE_INDEX)
if url is None:
raise _SkipTest("CBA_A3 not in index — skipping list_mod_files check")
files = _list_mod_files(url, _LIVE_SESSION)
assert len(files) > 0, "CBA_A3 has no files? Unexpected."
def _test_live_list_mod_files_tuple_shape():
"""Each entry from list_mod_files is a (rel_path, url, size) 3-tuple."""
_require_live()
mod = {"name": "CBA_A3", "steam_id": "450814997"}
url = _find_mod_folder(mod, _LIVE_INDEX)
if url is None:
raise _SkipTest("CBA_A3 not in index — skipping tuple shape check")
files = _list_mod_files(url, _LIVE_SESSION)
if not files:
raise _SkipTest("no files returned — skipping tuple shape check")
rel, file_url, size = files[0]
assert isinstance(rel, str) and rel, f"rel_path must be non-empty string, got {rel!r}"
assert isinstance(file_url, str) and file_url, f"file_url must be non-empty string"
assert isinstance(size, int) and size >= 0, f"size must be non-negative int, got {size!r}"
def _test_live_find_mod_by_name_fallback():
"""find_mod_folder can locate a mod by normalized name when steam_id is absent."""
_require_live()
# Use a mod with no steam_id — name-only lookup
mod = {"name": "CBA_A3", "steam_id": ""}
url = _find_mod_folder(mod, _LIVE_INDEX)
assert url is not None, "CBA_A3 not found via name fallback"
test("live: build_server_index returns expected structure", _test_live_index_structure)
test("live: server has at least one mod folder", _test_live_index_has_folders)
test("live: by_steam_id populated from meta.cpp files", _test_live_index_has_steam_id_entries)
test("live: by_name populated for all @ folders", _test_live_index_has_name_entries)
test("live: find_mod_folder locates CBA_A3 by steam_id", _test_live_find_mod_by_steam_id)
test("live: CBA_A3 folder URL returns HTTP 200", _test_live_find_mod_url_is_reachable)
test("live: list_mod_files returns non-empty list for CBA_A3", _test_live_list_mod_files_returns_entries)
test("live: list_mod_files entries are (rel_path, url, size) tuples", _test_live_list_mod_files_tuple_shape)
test("live: find_mod_folder name fallback works (no steam_id)", _test_live_find_mod_by_name_fallback)
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------