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:
766
test_suite.py
766
test_suite.py
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user