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

@@ -224,6 +224,36 @@ _EN: dict[str, str] = {
"tools.rm_btn": "Generate Report",
"tools.rm_last": "Last generated: {ts}",
"tools.rm_none": "No report yet.",
# ── Tools — Clean Orphans ────────────────────────────────────────────────
"tools.oc_desc": (
"Scan the downloads folder for mod folders that are no longer "
"referenced in comparison.json. These orphans accumulate when you "
"remove mods from your presets and re-run the pipeline. "
"Select the ones you want to remove to free up disk space."
),
"tools.oc_warn": (
"⚠ Deleting orphans permanently removes mod files from disk. "
"This cannot be undone."
),
"tools.oc_scan_btn": "Scan for Orphans",
"tools.oc_scanning": "Scanning…",
"tools.oc_no_config": "No config found. Complete Setup first.",
"tools.oc_no_comparison": "No comparison.json found — run the pipeline first.",
"tools.oc_none_found": "No orphans found. Your downloads folder is clean.",
"tools.oc_found": "{count} orphan(s) found — {size} total",
"tools.oc_sel_all": "Select All",
"tools.oc_sel_none": "Deselect All",
"tools.oc_delete_btn": "Delete Selected",
"tools.oc_confirm_title": "Confirm Delete",
"tools.oc_confirm_body": (
"Permanently delete {count} orphan folder(s) ({size})?\n\n"
"This cannot be undone."
),
"tools.oc_done": "Deleted {count} folder(s), freed {size}.",
"tools.oc_error": "Error deleting {path}: {e}",
"tools.oc_error_title": "Delete errors",
"tools.oc_scan_error": "Scan error: {e}",
}
_VI: dict[str, str] = {
@@ -431,6 +461,36 @@ _VI: dict[str, str] = {
"tools.rm_btn": "Tạo báo cáo",
"tools.rm_last": "Tạo lần cuối: {ts}",
"tools.rm_none": "Chưa có báo cáo.",
# ── Tools — Clean Orphans ────────────────────────────────────────────────
"tools.oc_desc": (
"Quét thư mục downloads để tìm các thư mục mod không còn được "
"tham chiếu trong comparison.json. Các mod mồ côi này tích tụ khi "
"bạn xóa mod khỏi preset và chạy lại pipeline. "
"Chọn các thư mục muốn xóa để giải phóng dung lượng."
),
"tools.oc_warn": (
"⚠ Xóa mod mồ côi sẽ xóa vĩnh viễn tệp mod khỏi ổ đĩa. "
"Thao tác này không thể hoàn tác."
),
"tools.oc_scan_btn": "Quét mod mồ côi",
"tools.oc_scanning": "Đang quét…",
"tools.oc_no_config": "Chưa tìm thấy cấu hình. Vui lòng hoàn thành thiết lập.",
"tools.oc_no_comparison": "Chưa có comparison.json — hãy chạy pipeline trước.",
"tools.oc_none_found": "Không tìm thấy mod mồ côi. Thư mục downloads sạch.",
"tools.oc_found": "Tìm thấy {count} mod mồ côi — tổng {size}",
"tools.oc_sel_all": "Chọn tất cả",
"tools.oc_sel_none": "Bỏ chọn",
"tools.oc_delete_btn": "Xóa đã chọn",
"tools.oc_confirm_title": "Xác nhận xóa",
"tools.oc_confirm_body": (
"Xóa vĩnh viễn {count} thư mục mồ côi ({size})?\n\n"
"Thao tác này không thể hoàn tác."
),
"tools.oc_done": "Đã xóa {count} thư mục, giải phóng {size}.",
"tools.oc_error": "Lỗi khi xóa {path}: {e}",
"tools.oc_error_title": "Lỗi xóa",
"tools.oc_scan_error": "Lỗi quét: {e}",
}
# Guard: both dicts must have identical key sets

View File

@@ -1,11 +1,15 @@
from __future__ import annotations
import json
import shutil
import threading
from tkinter import messagebox
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING
import customtkinter as ctk
from arma_modlist_tools.cleaner import find_orphan_folders
from arma_modlist_tools.linker import _is_junction, remove_junction
from gui._constants import COLOR_WARN, PROJECT_ROOT
from gui.locales import t
from gui.views.base import BaseView
@@ -41,6 +45,7 @@ class ToolsView(BaseView):
self._build_link_mods_tab()
self._build_sync_missing_tab()
self._build_report_missing_tab()
self._build_clean_orphans_tab()
# =========================================================================
# Public
@@ -378,6 +383,208 @@ class ToolsView(BaseView):
pass
self._rm_info.configure(text=t("tools.rm_none"))
# -------------------------------------------------------------------------
def _build_clean_orphans_tab(self) -> None:
self._tab_view.add("Clean Orphans")
tab = self._tab_view.tab("Clean Orphans")
tab.grid_columnconfigure(0, weight=1)
tab.grid_rowconfigure(3, weight=1)
desc_lbl = _desc(tab, row=0, text=t("tools.oc_desc"))
self._translatable.append((desc_lbl, "tools.oc_desc"))
oc_warn = ctk.CTkLabel(tab, text=t("tools.oc_warn"),
text_color=_WARN_COLOR, anchor="w")
oc_warn.grid(row=1, column=0, padx=24, pady=(0, 4), sticky="w")
self._translatable.append((oc_warn, "tools.oc_warn"))
self._oc_status = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w")
self._oc_status.grid(row=2, column=0, padx=24, pady=(0, 2), sticky="w")
# Scrollable list for results
self._oc_scroll = ctk.CTkScrollableFrame(tab)
self._oc_scroll.grid(row=3, column=0, sticky="nsew", padx=16, pady=(0, 4))
self._oc_scroll.grid_columnconfigure(0, weight=1)
# Bottom action bar
bot = ctk.CTkFrame(tab, fg_color="transparent")
bot.grid(row=4, column=0, sticky="ew", padx=16, pady=(4, 12))
self._oc_sel_all_btn = ctk.CTkButton(
bot, text=t("tools.oc_sel_all"), width=110,
command=self._oc_select_all,
)
self._oc_sel_all_btn.pack(side="left", padx=(0, 4))
self._translatable.append((self._oc_sel_all_btn, "tools.oc_sel_all"))
self._oc_sel_none_btn = ctk.CTkButton(
bot, text=t("tools.oc_sel_none"), width=110,
command=self._oc_deselect_all,
)
self._oc_sel_none_btn.pack(side="left", padx=4)
self._translatable.append((self._oc_sel_none_btn, "tools.oc_sel_none"))
self._oc_scan_btn = ctk.CTkButton(
bot, text=t("tools.oc_scan_btn"), width=150,
command=self._oc_scan,
)
self._oc_scan_btn.pack(side="right", padx=(4, 0))
self._translatable.append((self._oc_scan_btn, "tools.oc_scan_btn"))
self._oc_delete_btn = ctk.CTkButton(
bot, text=t("tools.oc_delete_btn"), width=150,
fg_color="darkred", hover_color="#8b0000",
command=self._oc_delete_selected,
state="disabled",
)
self._oc_delete_btn.pack(side="right", padx=4)
self._translatable.append((self._oc_delete_btn, "tools.oc_delete_btn"))
# Internal scan state
self._oc_orphans: list[dict] = []
self._oc_check_vars: list[ctk.BooleanVar] = []
self._oc_pending_done_msg: str | None = None
def _oc_scan(self) -> None:
cfg = self.app.cfg
if not cfg:
self._oc_status.configure(text=t("tools.oc_no_config"), text_color="gray")
return
if not cfg.comparison.exists():
self._oc_status.configure(text=t("tools.oc_no_comparison"), text_color="gray")
return
self._oc_scan_btn.configure(state="disabled", text=t("tools.oc_scanning"))
self._oc_delete_btn.configure(state="disabled")
self._oc_status.configure(text=t("tools.oc_scanning"), text_color="gray")
def _run() -> None:
try:
comparison = json.loads(cfg.comparison.read_text(encoding="utf-8"))
orphans = find_orphan_folders(cfg.downloads, comparison)
except Exception as e:
self.after(0, lambda: self._oc_scan_done(None, str(e)))
return
self.after(0, lambda: self._oc_scan_done(orphans, None))
threading.Thread(target=_run, daemon=True).start()
def _oc_scan_done(self, orphans: list[dict] | None, error: str | None) -> None:
self._oc_scan_btn.configure(state="normal", text=t("tools.oc_scan_btn"))
# Consume any pending success message from a previous delete operation
done_msg = self._oc_pending_done_msg
self._oc_pending_done_msg = None
# Clear previous results
for w in self._oc_scroll.winfo_children():
w.destroy()
self._oc_orphans = []
self._oc_check_vars = []
if error:
self._oc_status.configure(text=t("tools.oc_scan_error", e=error), text_color="red")
return
if not orphans:
msg = done_msg or t("tools.oc_none_found")
self._oc_status.configure(text=msg, text_color="gray")
return
total_size = sum(o["size"] for o in orphans)
self._oc_status.configure(
text=t("tools.oc_found", count=len(orphans), size=_fmt_size(total_size)),
text_color="gray",
)
self._oc_orphans = orphans
self._oc_delete_btn.configure(state="normal")
for i, orphan in enumerate(orphans):
var = ctk.BooleanVar(value=True)
self._oc_check_vars.append(var)
bg = ("gray90", "gray17") if i % 2 == 0 else ("gray86", "gray14")
row = ctk.CTkFrame(self._oc_scroll, fg_color=bg, corner_radius=4)
row.pack(fill="x", pady=1)
row.columnconfigure(1, weight=1)
ctk.CTkCheckBox(row, text="", variable=var, width=24).grid(
row=0, column=0, padx=(8, 4), pady=4,
)
ctk.CTkLabel(
row,
text=f" {orphan['group']} / {orphan['name']}",
anchor="w",
).grid(row=0, column=1, sticky="ew", padx=4)
ctk.CTkLabel(
row,
text=_fmt_size(orphan["size"]),
text_color="gray",
width=80,
anchor="e",
).grid(row=0, column=2, padx=(4, 12))
def _oc_select_all(self) -> None:
for var in self._oc_check_vars:
var.set(True)
def _oc_deselect_all(self) -> None:
for var in self._oc_check_vars:
var.set(False)
def _oc_delete_selected(self) -> None:
selected = [
self._oc_orphans[i]
for i, var in enumerate(self._oc_check_vars)
if var.get()
]
if not selected:
return
total_size = sum(o["size"] for o in selected)
confirmed = messagebox.askyesno(
t("tools.oc_confirm_title"),
t("tools.oc_confirm_body", count=len(selected), size=_fmt_size(total_size)),
)
if not confirmed:
return
self._oc_delete_btn.configure(state="disabled")
self._oc_scan_btn.configure(state="disabled")
def _run() -> None:
freed = 0
errors = []
for orphan in selected:
try:
p = orphan["path"]
if _is_junction(p):
# Safety: never rmtree a junction — it follows the
# reparse point and deletes the target's contents.
# Use remove_junction() which calls os.rmdir() instead.
ok, err = remove_junction(p)
if not ok:
errors.append(t("tools.oc_error", path=p.name, e=err))
continue
else:
shutil.rmtree(p)
freed += orphan["size"]
except Exception as e:
errors.append(t("tools.oc_error", path=orphan["path"].name, e=e))
self.after(0, lambda: self._oc_delete_done(len(selected), freed, errors))
threading.Thread(target=_run, daemon=True).start()
def _oc_delete_done(self, count: int, freed: int, errors: list[str]) -> None:
# Store success message so _oc_scan_done() can display it after the rescan
self._oc_pending_done_msg = (
None if errors
else t("tools.oc_done", count=count, size=_fmt_size(freed))
)
self._oc_scan_btn.configure(state="normal")
self._oc_scan()
if errors:
messagebox.showerror(t("tools.oc_error_title"), "\n".join(errors))
# =========================================================================
# Private — helpers
# =========================================================================
@@ -400,6 +607,21 @@ class ToolsView(BaseView):
self.app.run_tool(args)
# ---------------------------------------------------------------------------
# Size formatting helper
# ---------------------------------------------------------------------------
def _fmt_size(n: int) -> str:
"""Human-readable file size string."""
if n < 1024:
return f"{n} B"
if n < 1024 ** 2:
return f"{n / 1024:.1f} KB"
if n < 1024 ** 3:
return f"{n / 1024 ** 2:.1f} MB"
return f"{n / 1024 ** 3:.2f} GB"
# ---------------------------------------------------------------------------
# Layout helpers
# ---------------------------------------------------------------------------