Files
arma-modlist-tools/test_suite.py
revernomad17 9dea44fa3d check_names: add --fix-ids to repair wrong steam_ids in meta.cpp
ID_COLLISION status (previously NOT_ON_SERVER) now has its own label
and shows the bad steam_id in the report output.

--fix-ids cross-references comparison.json to find the correct
publishedid for each colliding folder and rewrites it in-place in
meta.cpp using regex, preserving all other content.

New helpers: _lookup_detailed (returns server_name + local_sid tuple),
_write_steam_id (regex-replace publishedid in meta.cpp),
_build_comparison_id_map (builds normalized_name -> steam_id map
from comparison.json).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:21:21 +07:00

1360 lines
49 KiB
Python

#!/usr/bin/env python3
"""
Comprehensive test suite for rev-arma-modlist-tools.
Run:
python test_suite.py
Tests are grouped by module. Network tests (fetcher) are skipped if the server
is unreachable. Linker tests use temp directories so they never touch real paths.
"""
import json
import os
import sys
import tempfile
import textwrap
import traceback
from pathlib import Path
# ---------------------------------------------------------------------------
# Test harness (no external dependencies)
# ---------------------------------------------------------------------------
_passed = _failed = _skipped = 0
_current_group = ""
def group(name: str) -> None:
global _current_group
_current_group = name
print(f"\n{'-'*60}")
print(f" {name}")
print(f"{'-'*60}")
def test(name: str, fn) -> None:
global _passed, _failed
try:
fn()
print(f" [PASS] {name}")
_passed += 1
except Exception as exc:
print(f" [FAIL] {name}")
for line in traceback.format_exc().splitlines():
print(f" {line}")
_failed += 1
def skip(name: str, reason: str) -> None:
global _skipped
print(f" [SKIP] {name} ({reason})")
_skipped += 1
def assert_eq(a, b, msg=""):
assert a == b, f"{msg}\n expected: {b!r}\n got: {a!r}"
def assert_in(item, container, msg=""):
assert item in container, f"{msg}\n {item!r} not in {container!r}"
def assert_raises(exc_type, fn, *args, **kwargs):
try:
fn(*args, **kwargs)
except exc_type:
return
raise AssertionError(f"Expected {exc_type.__name__} but no exception was raised")
# ---------------------------------------------------------------------------
# 1. compat
# ---------------------------------------------------------------------------
group("compat")
from arma_modlist_tools.compat import (
is_windows, is_linux, get_os_label, fix_console_encoding,
)
def _test_is_windows_linux_mutually_exclusive():
# Exactly one of is_windows()/is_linux() can be True; both can be False (Unknown)
assert not (is_windows() and is_linux()), "Cannot be both Windows and Linux"
def _test_os_label_is_string():
label = get_os_label()
assert isinstance(label, str) and label, "OS label must be non-empty string"
valid = {"Windows", "Windows Server", "Ubuntu", "Ubuntu Server", "Linux", "Unknown"}
assert label in valid, f"Unexpected OS label: {label!r}"
def _test_fix_console_encoding_idempotent():
# Calling it twice must not raise
fix_console_encoding()
fix_console_encoding()
def _test_is_windows_matches_platform():
import platform
assert is_windows() == (sys.platform == "win32")
def _test_is_linux_matches_platform():
assert is_linux() == (sys.platform == "linux")
def _test_os_label_consistent_with_platform():
label = get_os_label()
if is_windows():
assert "Windows" in label
elif is_linux():
assert label in {"Ubuntu", "Ubuntu Server", "Linux"}
else:
assert label == "Unknown"
test("is_windows and is_linux are mutually exclusive", _test_is_windows_linux_mutually_exclusive)
test("get_os_label returns a valid label", _test_os_label_is_string)
test("fix_console_encoding is idempotent", _test_fix_console_encoding_idempotent)
test("is_windows() matches sys.platform", _test_is_windows_matches_platform)
test("is_linux() matches sys.platform", _test_is_linux_matches_platform)
test("OS label is consistent with platform", _test_os_label_consistent_with_platform)
# ---------------------------------------------------------------------------
# 2. config
# ---------------------------------------------------------------------------
group("config")
from arma_modlist_tools.config import load_config, Config
_VALID_CONFIG = {
"server": {
"base_url": "https://example.com/mods/",
"username": "user",
"password": "pass",
},
"paths": {
"arma_dir": "C:\\arma3",
"downloads": "downloads",
"modlist_html": "modlist_html",
"modlist_json": "modlist_json",
},
}
def _test_load_config_from_explicit_path():
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(_VALID_CONFIG, f)
tmp = f.name
try:
cfg = load_config(tmp)
assert_eq(cfg.server_url, "https://example.com/mods/")
assert_eq(cfg.server_auth, ("user", "pass"))
assert_eq(cfg.arma_dir, Path("C:\\arma3"))
assert_eq(cfg.downloads, Path("downloads"))
assert_eq(cfg.modlist_html, Path("modlist_html"))
assert_eq(cfg.modlist_json, Path("modlist_json"))
assert_eq(cfg.comparison, Path("modlist_json/comparison.json"))
assert_eq(cfg.missing_report, Path("modlist_json/missing_report.json"))
finally:
os.unlink(tmp)
def _test_load_config_file_not_found():
assert_raises(FileNotFoundError, load_config, "nonexistent_1234567890.json")
def _test_load_config_missing_key_raises():
bad = {"server": {"base_url": "x"}} # missing paths
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(bad, f)
tmp = f.name
try:
assert_raises(KeyError, load_config, tmp)
finally:
os.unlink(tmp)
def _test_config_derived_paths():
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(_VALID_CONFIG, f)
tmp = f.name
try:
cfg = load_config(tmp)
# comparison must live inside modlist_json
assert cfg.comparison.parent == cfg.modlist_json
assert cfg.comparison.name == "comparison.json"
# missing_report must live inside modlist_json
assert cfg.missing_report.parent == cfg.modlist_json
assert cfg.missing_report.name == "missing_report.json"
finally:
os.unlink(tmp)
def _test_load_config_from_cwd():
"""load_config() without args should find the real config.json at CWD."""
original_cwd = Path.cwd()
project_root = Path(__file__).parent
os.chdir(project_root)
try:
cfg = load_config()
assert cfg.server_url, "server_url must not be empty"
finally:
os.chdir(original_cwd)
test("load_config with explicit path", _test_load_config_from_explicit_path)
test("load_config raises FileNotFoundError for missing file", _test_load_config_file_not_found)
test("load_config raises KeyError for incomplete config", _test_load_config_missing_key_raises)
test("Config derived paths (comparison, missing_report)", _test_config_derived_paths)
test("load_config() auto-discovers config.json from CWD", _test_load_config_from_cwd)
# ---------------------------------------------------------------------------
# 3. parser
# ---------------------------------------------------------------------------
group("parser")
from arma_modlist_tools.parser import (
parse_mod_entry, parse_modlist_html, parse_modlist_dir,
)
import xml.etree.ElementTree as ET
_VALID_TR_XML = textwrap.dedent("""\
<tr data-type="ModContainer">
<td data-type="DisplayName">CBA_A3</td>
<td>
<span class="from-steam"></span>
<a data-type="Link" href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997"></a>
</td>
</tr>
""")
_LOCAL_TR_XML = textwrap.dedent("""\
<tr data-type="ModContainer">
<td data-type="DisplayName">My Local Mod</td>
<td>
<span class="from-local"></span>
</td>
</tr>
""")
_NO_NAME_TR_XML = textwrap.dedent("""\
<tr data-type="ModContainer">
<td>
<a data-type="Link" href="https://steamcommunity.com/sharedfiles/filedetails/?id=123"></a>
</td>
</tr>
""")
_MINIMAL_HTML = textwrap.dedent("""\
<?xml version="1.0" encoding="utf-8"?>
<html>
<head><title>Test</title></head>
<body>
<table>
<tr data-type="ModContainer">
<td data-type="DisplayName">ACE3</td>
<td><span class="from-steam"></span>
<a data-type="Link" href="https://steamcommunity.com/sharedfiles/filedetails/?id=463939057"></a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">CBA_A3</td>
<td><span class="from-steam"></span>
<a data-type="Link" href="https://steamcommunity.com/sharedfiles/filedetails/?id=450814997"></a>
</td>
</tr>
<tr data-type="ModContainer">
<td data-type="DisplayName">LocalMod</td>
<td><span class="from-local"></span></td>
</tr>
</table>
</body>
</html>
""")
def _test_parse_mod_entry_steam():
tr = ET.fromstring(_VALID_TR_XML)
entry = parse_mod_entry(tr)
assert entry is not None
assert_eq(entry["name"], "CBA_A3")
assert_eq(entry["source"], "steam")
assert_eq(entry["steam_id"], "450814997")
assert "450814997" in entry["url"]
def _test_parse_mod_entry_local():
tr = ET.fromstring(_LOCAL_TR_XML)
entry = parse_mod_entry(tr)
assert entry is not None
assert_eq(entry["name"], "My Local Mod")
assert_eq(entry["source"], "local")
assert entry["steam_id"] is None
assert entry["url"] is None
def _test_parse_mod_entry_no_name_returns_none():
tr = ET.fromstring(_NO_NAME_TR_XML)
entry = parse_mod_entry(tr)
assert entry is None, "Entry without DisplayName must return None"
def _test_parse_modlist_html_from_file():
with tempfile.NamedTemporaryFile(
mode="w", suffix=".html", delete=False, encoding="utf-8"
) as f:
f.write(_MINIMAL_HTML)
tmp = Path(f.name)
try:
preset = parse_modlist_html(tmp)
assert_eq(preset["source_file"], tmp.name)
assert_eq(preset["mod_count"], 3)
assert len(preset["mods"]) == 3
names = [m["name"] for m in preset["mods"]]
assert_in("ACE3", names)
assert_in("CBA_A3", names)
assert_in("LocalMod", names)
# steam IDs
steam_ids = {m["steam_id"] for m in preset["mods"]}
assert_in("463939057", steam_ids)
assert_in("450814997", steam_ids)
assert_in(None, steam_ids) # LocalMod has no steam_id
finally:
tmp.unlink()
def _test_parse_modlist_html_not_found():
assert_raises(FileNotFoundError, parse_modlist_html, "no_such_file_xyz.html")
def _test_parse_modlist_dir_real_files():
d = Path(__file__).parent / "modlist_html"
if not d.is_dir():
raise AssertionError(f"modlist_html directory not found: {d}")
presets = parse_modlist_dir(d)
assert len(presets) >= 2, f"Expected >=2 presets, got {len(presets)}"
for p in presets:
assert p["mod_count"] > 0, f"{p['source_file']} has 0 mods"
assert len(p["mods"]) == p["mod_count"]
def _test_parse_modlist_dir_empty():
with tempfile.TemporaryDirectory() as d:
result = parse_modlist_dir(d)
assert_eq(result, [])
def _test_parse_modlist_dir_not_a_dir():
assert_raises(NotADirectoryError, parse_modlist_dir, "no_such_directory_xyz")
def _test_parse_modlist_dir_returns_sorted():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
for name in ["c_preset.html", "a_preset.html", "b_preset.html"]:
f = d / name
f.write_text(_MINIMAL_HTML, encoding="utf-8")
presets = parse_modlist_dir(d)
names = [p["source_file"] for p in presets]
assert names == sorted(names), "parse_modlist_dir must return results sorted by filename"
test("parse_mod_entry: Steam mod", _test_parse_mod_entry_steam)
test("parse_mod_entry: Local mod", _test_parse_mod_entry_local)
test("parse_mod_entry: No name returns None", _test_parse_mod_entry_no_name_returns_none)
test("parse_modlist_html: from temp HTML file", _test_parse_modlist_html_from_file)
test("parse_modlist_html: FileNotFoundError", _test_parse_modlist_html_not_found)
test("parse_modlist_dir: real modlist_html/ files", _test_parse_modlist_dir_real_files)
test("parse_modlist_dir: empty directory", _test_parse_modlist_dir_empty)
test("parse_modlist_dir: non-existent directory", _test_parse_modlist_dir_not_a_dir)
test("parse_modlist_dir: results are sorted by filename", _test_parse_modlist_dir_returns_sorted)
# ---------------------------------------------------------------------------
# 4. compare
# ---------------------------------------------------------------------------
group("compare")
from arma_modlist_tools.compare import compare_presets
_P1 = {
"preset_name": "Preset_A",
"source_file": "preset_a.html",
"mod_count": 3,
"mods": [
{"name": "CBA_A3", "source": "steam", "url": None, "steam_id": "450814997"},
{"name": "ACE3", "source": "steam", "url": None, "steam_id": "463939057"},
{"name": "OnlyInA", "source": "steam", "url": None, "steam_id": "111111111"},
],
}
_P2 = {
"preset_name": "Preset_B",
"source_file": "preset_b.html",
"mod_count": 3,
"mods": [
{"name": "CBA_A3", "source": "steam", "url": None, "steam_id": "450814997"},
{"name": "ACE3", "source": "steam", "url": None, "steam_id": "463939057"},
{"name": "OnlyInB", "source": "steam", "url": None, "steam_id": "222222222"},
],
}
_P_LOCAL = {
"preset_name": "Preset_Local",
"source_file": "preset_local.html",
"mod_count": 2,
"mods": [
{"name": "CBA_A3", "source": "steam", "url": None, "steam_id": "450814997"},
{"name": "MyLocalMod", "source": "local", "url": None, "steam_id": None},
],
}
_P_IDENTICAL = {
"preset_name": "Preset_C",
"source_file": "preset_c.html",
"mod_count": 3,
"mods": [
{"name": "CBA_A3", "source": "steam", "url": None, "steam_id": "450814997"},
{"name": "ACE3", "source": "steam", "url": None, "steam_id": "463939057"},
{"name": "OnlyInA", "source": "steam", "url": None, "steam_id": "111111111"},
],
}
def _test_compare_basic_shared_and_unique():
result = compare_presets(_P1, _P2)
assert_eq(sorted(result["compared_presets"]), ["Preset_A", "Preset_B"])
shared_ids = {m["steam_id"] for m in result["shared"]["mods"]}
assert_eq(shared_ids, {"450814997", "463939057"})
assert_eq(result["shared"]["mod_count"], 2)
unique_a = {m["steam_id"] for m in result["unique"]["Preset_A"]["mods"]}
unique_b = {m["steam_id"] for m in result["unique"]["Preset_B"]["mods"]}
assert_eq(unique_a, {"111111111"})
assert_eq(unique_b, {"222222222"})
def _test_compare_requires_two_or_more():
assert_raises(ValueError, compare_presets, _P1)
def _test_compare_three_presets():
result = compare_presets(_P1, _P2, _P_LOCAL)
# Only CBA_A3 (450814997) is in all three
shared_ids = {m["steam_id"] for m in result["shared"]["mods"]}
assert_eq(shared_ids, {"450814997"})
assert "Preset_Local" in result["unique"]
def _test_compare_identical_presets_all_shared():
result = compare_presets(_P1, _P_IDENTICAL)
assert_eq(result["shared"]["mod_count"], 3)
for data in result["unique"].values():
assert_eq(data["mod_count"], 0)
def _test_compare_no_shared_mods():
p_no_overlap = {
"preset_name": "Preset_X",
"source_file": "px.html",
"mod_count": 1,
"mods": [{"name": "Exclusive", "source": "steam", "url": None, "steam_id": "999999999"}],
}
result = compare_presets(_P1, p_no_overlap)
assert_eq(result["shared"]["mod_count"], 0)
assert_eq(len(result["shared"]["mods"]), 0)
def _test_compare_local_mod_uses_name_as_key():
# Two presets share a local mod by name (no steam_id)
p_a = {
"preset_name": "A",
"source_file": "a.html",
"mod_count": 1,
"mods": [{"name": "MyLocalMod", "source": "local", "url": None, "steam_id": None}],
}
p_b = {
"preset_name": "B",
"source_file": "b.html",
"mod_count": 1,
"mods": [{"name": "MyLocalMod", "source": "local", "url": None, "steam_id": None}],
}
result = compare_presets(p_a, p_b)
assert_eq(result["shared"]["mod_count"], 1)
assert_eq(result["shared"]["mods"][0]["name"], "MyLocalMod")
def _test_compare_preserves_all_fields():
result = compare_presets(_P1, _P2)
for mod in result["shared"]["mods"]:
assert "name" in mod
assert "source" in mod
assert "steam_id" in mod
def _test_compare_result_counts_consistent():
result = compare_presets(_P1, _P2)
for preset in (_P1, _P2):
pname = preset["preset_name"]
total = result["shared"]["mod_count"] + result["unique"][pname]["mod_count"]
assert_eq(total, preset["mod_count"],
f"shared + unique must equal total for {pname}")
test("compare_presets: shared and unique breakdown", _test_compare_basic_shared_and_unique)
test("compare_presets: requires >= 2 presets", _test_compare_requires_two_or_more)
test("compare_presets: three presets, intersection only", _test_compare_three_presets)
test("compare_presets: identical presets → all shared", _test_compare_identical_presets_all_shared)
test("compare_presets: no overlap → shared is empty", _test_compare_no_shared_mods)
test("compare_presets: local mod matched by name", _test_compare_local_mod_uses_name_as_key)
test("compare_presets: result mods retain all fields", _test_compare_preserves_all_fields)
test("compare_presets: shared + unique = total per preset", _test_compare_result_counts_consistent)
# ---------------------------------------------------------------------------
# 5. fetcher (pure functions only — no network)
# ---------------------------------------------------------------------------
group("fetcher (pure functions, no network)")
from arma_modlist_tools.fetcher import (
_normalize_name, _parse_meta_cpp, _folder_url, find_mod_folder,
)
def _test_normalize_name_strips_at():
assert_eq(_normalize_name("@ace"), "ace")
assert_eq(_normalize_name("@CBA_A3"), "cbaa3")
def _test_normalize_name_lowercase():
assert_eq(_normalize_name("@RHS_USAF"), "rhsusaf")
def _test_normalize_name_removes_spaces_and_special():
assert_eq(_normalize_name("@My Mod (v2)"), "mymodv2")
def _test_normalize_name_no_at():
assert_eq(_normalize_name("ace"), "ace")
def _test_parse_meta_cpp_standard():
txt = 'publishedid = 463939057;\nname = "ACE3";'
assert_eq(_parse_meta_cpp(txt), "463939057")
def _test_parse_meta_cpp_no_spaces():
assert_eq(_parse_meta_cpp("publishedid=123456;"), "123456")
def _test_parse_meta_cpp_case_insensitive():
assert_eq(_parse_meta_cpp("PublishedId = 999;"), "999")
def _test_parse_meta_cpp_missing():
assert _parse_meta_cpp("name = somemod;") is None
def _test_folder_url_trailing_slash():
url = _folder_url("https://example.com/mods", "@ace")
assert url.endswith("/"), "folder_url must end with /"
assert "@ace" in url
def _test_find_mod_folder_by_steam_id():
index = {
"by_steam_id": {"450814997": "https://example.com/mods/@cba_a3/"},
"by_name": {},
}
mod = {"steam_id": "450814997", "name": "CBA_A3"}
url = find_mod_folder(mod, index)
assert_eq(url, "https://example.com/mods/@cba_a3/")
def _test_find_mod_folder_by_name_fallback():
index = {
"by_steam_id": {},
"by_name": {"ace3": "https://example.com/mods/@ace3/"},
}
mod = {"steam_id": None, "name": "ACE3"}
url = find_mod_folder(mod, index)
assert_eq(url, "https://example.com/mods/@ace3/")
def _test_find_mod_folder_steam_id_preferred_over_name():
index = {
"by_steam_id": {"111": "https://example.com/mods/@right/"},
"by_name": {"mymod": "https://example.com/mods/@wrong/"},
}
mod = {"steam_id": "111", "name": "MyMod"}
url = find_mod_folder(mod, index)
assert_eq(url, "https://example.com/mods/@right/")
def _test_find_mod_folder_not_found():
index = {"by_steam_id": {}, "by_name": {}}
mod = {"steam_id": "999", "name": "Missing"}
assert find_mod_folder(mod, index) is None
def _test_find_mod_folder_normalized_name_match():
index = {
"by_steam_id": {},
"by_name": {"rhsusaf": "https://example.com/mods/@rhs_usaf/"},
}
mod = {"steam_id": None, "name": "@RHS_USAF"}
url = find_mod_folder(mod, index)
assert_eq(url, "https://example.com/mods/@rhs_usaf/")
test("_normalize_name: strips leading @", _test_normalize_name_strips_at)
test("_normalize_name: lowercases", _test_normalize_name_lowercase)
test("_normalize_name: removes spaces and special chars", _test_normalize_name_removes_spaces_and_special)
test("_normalize_name: no @ prefix still works", _test_normalize_name_no_at)
test("_parse_meta_cpp: standard format", _test_parse_meta_cpp_standard)
test("_parse_meta_cpp: no spaces", _test_parse_meta_cpp_no_spaces)
test("_parse_meta_cpp: case-insensitive", _test_parse_meta_cpp_case_insensitive)
test("_parse_meta_cpp: missing → None", _test_parse_meta_cpp_missing)
test("_folder_url: always trailing slash", _test_folder_url_trailing_slash)
test("find_mod_folder: by steam_id", _test_find_mod_folder_by_steam_id)
test("find_mod_folder: by name fallback", _test_find_mod_folder_by_name_fallback)
test("find_mod_folder: steam_id preferred over name", _test_find_mod_folder_steam_id_preferred_over_name)
test("find_mod_folder: not found → None", _test_find_mod_folder_not_found)
test("find_mod_folder: normalized @ name match", _test_find_mod_folder_normalized_name_match)
# ---- list_mod_updates (uses temp dirs, no network) ----
from arma_modlist_tools.fetcher import list_mod_updates
import unittest.mock as mock
def _test_list_mod_updates_all_uptodate():
"""All local files exist with matching sizes → empty result."""
with tempfile.TemporaryDirectory() as d:
dest = Path(d)
# Create local files matching server sizes
(dest / "addons").mkdir()
(dest / "addons" / "mod.pbo").write_bytes(b"x" * 1000)
server_files = [("addons/mod.pbo", "https://x.com/addons/mod.pbo", 1000)]
with mock.patch("arma_modlist_tools.fetcher.list_mod_files", return_value=server_files):
result = list_mod_updates("https://x.com/", dest, None)
assert_eq(result, [], "No stale files expected when sizes match")
def _test_list_mod_updates_missing_file():
"""Local file does not exist → included."""
with tempfile.TemporaryDirectory() as d:
dest = Path(d)
server_files = [("addons/new.pbo", "https://x.com/addons/new.pbo", 500)]
with mock.patch("arma_modlist_tools.fetcher.list_mod_files", return_value=server_files):
result = list_mod_updates("https://x.com/", dest, None)
assert_eq(len(result), 1)
assert_eq(result[0][0], "addons/new.pbo")
def _test_list_mod_updates_size_mismatch():
"""Local file exists but size differs → included."""
with tempfile.TemporaryDirectory() as d:
dest = Path(d)
(dest / "addons").mkdir()
(dest / "addons" / "mod.pbo").write_bytes(b"x" * 999) # local: 999 bytes
server_files = [("addons/mod.pbo", "https://x.com/addons/mod.pbo", 1000)] # server: 1000
with mock.patch("arma_modlist_tools.fetcher.list_mod_files", return_value=server_files):
result = list_mod_updates("https://x.com/", dest, None)
assert_eq(len(result), 1)
assert_eq(result[0][2], 1000, "Returned entry must carry server size")
def _test_list_mod_updates_zero_server_size_skips_check():
"""Server reports size=0 (unknown) — only include if file is missing."""
with tempfile.TemporaryDirectory() as d:
dest = Path(d)
(dest / "addons").mkdir()
(dest / "addons" / "mod.pbo").write_bytes(b"x" * 500) # exists locally
server_files = [("addons/mod.pbo", "https://x.com/addons/mod.pbo", 0)]
with mock.patch("arma_modlist_tools.fetcher.list_mod_files", return_value=server_files):
result = list_mod_updates("https://x.com/", dest, None)
# size=0 → skip size check, file exists → not stale
assert_eq(result, [], "Existing file with server size=0 must not be re-downloaded")
def _test_list_mod_updates_mixed():
"""One up-to-date, one missing, one stale → two results."""
with tempfile.TemporaryDirectory() as d:
dest = Path(d)
(dest / "addons").mkdir()
(dest / "addons" / "current.pbo").write_bytes(b"x" * 100) # matches server
(dest / "addons" / "stale.pbo").write_bytes(b"x" * 50) # differs from server
# missing.pbo does not exist
server_files = [
("addons/current.pbo", "https://x.com/addons/current.pbo", 100),
("addons/stale.pbo", "https://x.com/addons/stale.pbo", 200),
("addons/missing.pbo", "https://x.com/addons/missing.pbo", 300),
]
with mock.patch("arma_modlist_tools.fetcher.list_mod_files", return_value=server_files):
result = list_mod_updates("https://x.com/", dest, None)
result_names = {r[0] for r in result}
assert_eq(result_names, {"addons/stale.pbo", "addons/missing.pbo"})
assert "addons/current.pbo" not in result_names
test("list_mod_updates: all files up-to-date → empty", _test_list_mod_updates_all_uptodate)
test("list_mod_updates: missing file → included", _test_list_mod_updates_missing_file)
test("list_mod_updates: size mismatch → included", _test_list_mod_updates_size_mismatch)
test("list_mod_updates: server size=0 → skip size check", _test_list_mod_updates_zero_server_size_skips_check)
test("list_mod_updates: mixed state (up-to-date, stale, missing)", _test_list_mod_updates_mixed)
# ---------------------------------------------------------------------------
# 6. reporter
# ---------------------------------------------------------------------------
group("reporter")
from arma_modlist_tools.reporter import build_missing_report, save_missing_report
_COMPARISON = {
"compared_presets": ["Preset_A", "Preset_B"],
"shared": {
"mod_count": 2,
"mods": [
{"name": "CBA_A3", "steam_id": "450814997", "url": None, "source": "steam"},
{"name": "ACE3", "steam_id": "463939057", "url": None, "source": "steam"},
],
},
"unique": {
"Preset_A": {
"mod_count": 1,
"mods": [{"name": "OnlyA", "steam_id": "111", "url": None, "source": "steam"}],
},
"Preset_B": {
"mod_count": 1,
"mods": [{"name": "OnlyB", "steam_id": "222", "url": None, "source": "steam"}],
},
},
}
_INDEX_ALL = {
"by_steam_id": {
"450814997": "https://x.com/@cba/",
"463939057": "https://x.com/@ace/",
"111": "https://x.com/@onlya/",
"222": "https://x.com/@onlyb/",
},
"by_name": {},
}
_INDEX_NONE: dict = {"by_steam_id": {}, "by_name": {}}
_INDEX_PARTIAL = {
"by_steam_id": {
"450814997": "https://x.com/@cba/",
"463939057": "https://x.com/@ace/",
},
"by_name": {},
}
def _test_build_missing_report_all_found():
report = build_missing_report(_COMPARISON, _INDEX_ALL)
assert_eq(report["total_mods"], 4)
assert_eq(report["on_server"], 4)
assert_eq(report["missing"], 0)
assert_eq(report["missing_mods"], [])
def _test_build_missing_report_all_missing():
report = build_missing_report(_COMPARISON, _INDEX_NONE)
assert_eq(report["total_mods"], 4)
assert_eq(report["on_server"], 0)
assert_eq(report["missing"], 4)
assert_eq(len(report["missing_mods"]), 4)
def _test_build_missing_report_partial():
report = build_missing_report(_COMPARISON, _INDEX_PARTIAL)
assert_eq(report["on_server"], 2)
assert_eq(report["missing"], 2)
missing_ids = {m["steam_id"] for m in report["missing_mods"]}
assert_eq(missing_ids, {"111", "222"})
def _test_build_missing_report_group_field():
report = build_missing_report(_COMPARISON, _INDEX_NONE)
groups = {m["group"] for m in report["missing_mods"]}
assert_in("shared", groups)
assert_in("Preset_A", groups)
assert_in("Preset_B", groups)
def _test_build_missing_report_shared_group_label():
report = build_missing_report(_COMPARISON, _INDEX_NONE)
shared_entries = [m for m in report["missing_mods"] if m["name"] in ("CBA_A3", "ACE3")]
for e in shared_entries:
assert_eq(e["group"], "shared", f"{e['name']} must have group='shared'")
def _test_build_missing_report_has_timestamp():
report = build_missing_report(_COMPARISON, _INDEX_ALL)
assert "generated_at" in report
assert isinstance(report["generated_at"], str)
assert "T" in report["generated_at"] # ISO 8601 format
def _test_save_and_reload_missing_report():
report = build_missing_report(_COMPARISON, _INDEX_PARTIAL)
with tempfile.TemporaryDirectory() as d:
out = Path(d) / "sub" / "missing_report.json"
save_missing_report(report, out)
assert out.exists(), "save_missing_report must create the file"
loaded = json.loads(out.read_text(encoding="utf-8"))
assert_eq(loaded["missing"], report["missing"])
assert_eq(loaded["total_mods"], report["total_mods"])
def _test_save_missing_report_creates_parent_dirs():
with tempfile.TemporaryDirectory() as d:
deep = Path(d) / "a" / "b" / "c" / "report.json"
save_missing_report({"test": True}, deep)
assert deep.exists()
test("build_missing_report: all mods found", _test_build_missing_report_all_found)
test("build_missing_report: all mods missing", _test_build_missing_report_all_missing)
test("build_missing_report: partial coverage", _test_build_missing_report_partial)
test("build_missing_report: group field present on all entries", _test_build_missing_report_group_field)
test("build_missing_report: shared mods labeled group='shared'", _test_build_missing_report_shared_group_label)
test("build_missing_report: includes ISO timestamp", _test_build_missing_report_has_timestamp)
test("save_missing_report: write and reload roundtrip", _test_save_and_reload_missing_report)
test("save_missing_report: creates parent directories", _test_save_missing_report_creates_parent_dirs)
# ---------------------------------------------------------------------------
# 7. linker
# ---------------------------------------------------------------------------
group("linker")
from arma_modlist_tools.linker import (
get_mod_folders, get_link_status,
create_junction, remove_junction,
link_group, unlink_group,
_is_junction,
)
def _make_fake_mods(base: Path, names: list[str]) -> None:
"""Create @ModName directories with a dummy file inside each."""
for name in names:
mod_dir = base / name
mod_dir.mkdir(parents=True)
(mod_dir / "mod.cpp").write_text(f"// {name}", encoding="utf-8")
def _test_get_mod_folders_finds_at_dirs():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
_make_fake_mods(d, ["@ace", "@cba_a3", "@rhs"])
(d / "not_a_mod").mkdir() # should be ignored
(d / "readme.txt").write_text("x") # should be ignored
folders = get_mod_folders(d)
names = [f.name for f in folders]
assert_eq(sorted(names), ["@ace", "@cba_a3", "@rhs"])
assert "not_a_mod" not in names
def _test_get_mod_folders_empty_dir():
with tempfile.TemporaryDirectory() as d:
folders = get_mod_folders(Path(d))
assert_eq(folders, [])
def _test_get_mod_folders_nonexistent():
result = get_mod_folders(Path("no_such_dir_xyz_abc"))
assert_eq(result, [])
def _test_get_link_status_unlinked():
with tempfile.TemporaryDirectory() as src_d, tempfile.TemporaryDirectory() as arma_d:
src_d = Path(src_d)
arma_d = Path(arma_d)
_make_fake_mods(src_d, ["@ace", "@cba_a3"])
status = get_link_status(src_d, arma_d)
assert_eq(len(status), 2)
for s in status:
assert not s["is_linked"], f"{s['name']} should not be linked yet"
assert s["link_path"].parent == arma_d
assert s["source_path"].exists()
def _test_create_and_detect_junction():
with tempfile.TemporaryDirectory() as src_d, tempfile.TemporaryDirectory() as arma_d:
src_d = Path(src_d)
arma_d = Path(arma_d)
_make_fake_mods(src_d, ["@mymod"])
target = src_d / "@mymod"
link = arma_d / "@mymod"
ok = create_junction(link, target)
assert ok, "create_junction must return True on success"
assert link.exists(), "link must exist after creation"
assert _is_junction(link), "_is_junction must detect the new junction"
# Verify junction points to the right files
assert (link / "mod.cpp").exists(), "linked junction must expose target contents"
def _test_remove_junction():
with tempfile.TemporaryDirectory() as src_d, tempfile.TemporaryDirectory() as arma_d:
src_d = Path(src_d)
arma_d = Path(arma_d)
_make_fake_mods(src_d, ["@mymod"])
target = src_d / "@mymod"
link = arma_d / "@mymod"
create_junction(link, target)
assert _is_junction(link)
ok, err = remove_junction(link)
assert ok, f"remove_junction failed: {err}"
assert not link.exists(), "junction must be gone after removal"
# Target must be untouched
assert target.exists(), "target directory must survive junction removal"
assert (target / "mod.cpp").exists(), "target files must survive junction removal"
def _test_link_group_full_flow():
with tempfile.TemporaryDirectory() as src_d, tempfile.TemporaryDirectory() as arma_d:
src_d = Path(src_d)
arma_d = Path(arma_d)
_make_fake_mods(src_d, ["@ace", "@cba_a3", "@rhs"])
result = link_group(src_d, arma_d)
assert_eq(result["linked"], 3)
assert_eq(result["already_linked"], 0)
assert_eq(result["failed"], 0)
# All links exist
for name in ["@ace", "@cba_a3", "@rhs"]:
assert _is_junction(arma_d / name), f"{name} must be linked"
def _test_link_group_idempotent():
with tempfile.TemporaryDirectory() as src_d, tempfile.TemporaryDirectory() as arma_d:
src_d = Path(src_d)
arma_d = Path(arma_d)
_make_fake_mods(src_d, ["@ace"])
link_group(src_d, arma_d)
result2 = link_group(src_d, arma_d)
assert_eq(result2["linked"], 0)
assert_eq(result2["already_linked"], 1)
assert_eq(result2["failed"], 0)
def _test_unlink_group_removes_links():
with tempfile.TemporaryDirectory() as src_d, tempfile.TemporaryDirectory() as arma_d:
src_d = Path(src_d)
arma_d = Path(arma_d)
_make_fake_mods(src_d, ["@ace", "@cba_a3"])
link_group(src_d, arma_d)
result = unlink_group(src_d, arma_d)
assert_eq(result["unlinked"], 2)
assert_eq(result["not_linked"], 0)
assert_eq(result["failed"], 0)
# Links gone, sources still there
for name in ["@ace", "@cba_a3"]:
assert not (arma_d / name).exists(), f"Link {name} must be gone"
assert (src_d / name).exists(), f"Source {name} must survive"
def _test_unlink_group_nothing_linked():
with tempfile.TemporaryDirectory() as src_d, tempfile.TemporaryDirectory() as arma_d:
src_d = Path(src_d)
arma_d = Path(arma_d)
_make_fake_mods(src_d, ["@ace"])
result = unlink_group(src_d, arma_d)
assert_eq(result["unlinked"], 0)
assert_eq(result["not_linked"], 1)
def _test_get_link_status_after_linking():
with tempfile.TemporaryDirectory() as src_d, tempfile.TemporaryDirectory() as arma_d:
src_d = Path(src_d)
arma_d = Path(arma_d)
_make_fake_mods(src_d, ["@ace"])
link_group(src_d, arma_d)
status = get_link_status(src_d, arma_d)
assert_eq(len(status), 1)
assert status[0]["is_linked"], "status must reflect active junction"
def _test_link_group_skips_existing_non_junction():
"""If arma_dir already has a regular folder with the same name, must not overwrite."""
with tempfile.TemporaryDirectory() as src_d, tempfile.TemporaryDirectory() as arma_d:
src_d = Path(src_d)
arma_d = Path(arma_d)
_make_fake_mods(src_d, ["@ace"])
# Create a plain (non-junction) directory at the link path
(arma_d / "@ace").mkdir()
result = link_group(src_d, arma_d)
assert_eq(result["failed"], 1, "Must report failure for path that exists but is not a junction")
assert_eq(result["linked"], 0)
test("get_mod_folders: finds @-prefixed dirs only", _test_get_mod_folders_finds_at_dirs)
test("get_mod_folders: empty directory", _test_get_mod_folders_empty_dir)
test("get_mod_folders: nonexistent path → []", _test_get_mod_folders_nonexistent)
test("get_link_status: all unlinked initially", _test_get_link_status_unlinked)
test("create_junction + _is_junction detection", _test_create_and_detect_junction)
test("remove_junction: removes link, target untouched", _test_remove_junction)
test("link_group: full link flow", _test_link_group_full_flow)
test("link_group: idempotent (already_linked on second call)", _test_link_group_idempotent)
test("unlink_group: removes all links", _test_unlink_group_removes_links)
test("unlink_group: nothing linked → not_linked count", _test_unlink_group_nothing_linked)
test("get_link_status: reflects active junctions", _test_get_link_status_after_linking)
test("link_group: skips plain dir (fails safely)", _test_link_group_skips_existing_non_junction)
# ---------------------------------------------------------------------------
# 8. __init__ exports
# ---------------------------------------------------------------------------
group("__init__ public exports")
import arma_modlist_tools as pkg
_EXPECTED_EXPORTS = [
"parse_mod_entry", "parse_modlist_html", "parse_modlist_dir",
"compare_presets",
"make_session", "build_server_index", "find_mod_folder",
"list_mod_files", "list_mod_updates", "download_file", "download_mod_folder",
"get_mod_folders", "get_link_status",
"create_junction", "remove_junction",
"link_group", "unlink_group",
"load_config", "Config",
"is_windows", "is_linux", "get_os_label", "fix_console_encoding",
"build_missing_report", "save_missing_report",
]
def _test_all_exports_present():
missing = [name for name in _EXPECTED_EXPORTS if not hasattr(pkg, name)]
assert not missing, f"Missing exports from arma_modlist_tools: {missing}"
def _test_exports_are_callable():
non_callable = [
name for name in _EXPECTED_EXPORTS
if hasattr(pkg, name) and not callable(getattr(pkg, name))
and not isinstance(getattr(pkg, name), type)
]
assert not non_callable, f"Non-callable exports: {non_callable}"
test("all expected symbols exported", _test_all_exports_present)
test("all exported symbols are callable or types", _test_exports_are_callable)
# ---------------------------------------------------------------------------
# 9. check_names helpers
# ---------------------------------------------------------------------------
group("check_names helpers")
from check_names import (
_server_name_from_url, _read_local_steam_id,
_lookup_detailed, _resolve_status, _write_steam_id,
_build_comparison_id_map,
)
def _test_server_name_from_url():
assert_eq(_server_name_from_url("https://x.com/mods/@ace/"), "@ace")
assert_eq(_server_name_from_url("https://x.com/mods/@cba_a3"), "@cba_a3")
def _test_read_local_steam_id_present():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
(d / "meta.cpp").write_text('publishedid = 463939057;\nname = "ACE3";', encoding="utf-8")
assert_eq(_read_local_steam_id(d), "463939057")
def _test_read_local_steam_id_missing():
with tempfile.TemporaryDirectory() as d:
assert _read_local_steam_id(Path(d)) is None
def _test_read_local_steam_id_no_id_in_file():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
(d / "meta.cpp").write_text('name = "LocalMod";\n', encoding="utf-8")
assert _read_local_steam_id(d) is None
def _test_lookup_detailed_by_steam_id():
index = {
"by_steam_id": {"463939057": "https://x.com/@ace3/"},
"by_name": {},
}
with tempfile.TemporaryDirectory() as d:
d = Path(d)
(d / "meta.cpp").write_text("publishedid = 463939057;", encoding="utf-8")
server_name, local_sid = _lookup_detailed("@ACE3", d, index)
assert_eq(server_name, "@ace3")
assert_eq(local_sid, "463939057")
def _test_lookup_detailed_by_name_fallback():
index = {
"by_steam_id": {},
"by_name": {"cbaa3": "https://x.com/@cba_a3/"},
}
with tempfile.TemporaryDirectory() as d:
server_name, local_sid = _lookup_detailed("@CBA_A3", Path(d), index)
assert_eq(server_name, "@cba_a3")
assert local_sid is None # no meta.cpp
def _test_lookup_detailed_steam_id_beats_name():
index = {
"by_steam_id": {"111": "https://x.com/@correct/"},
"by_name": {"mymod": "https://x.com/@wrong/"},
}
with tempfile.TemporaryDirectory() as d:
d = Path(d)
(d / "meta.cpp").write_text("publishedid = 111;", encoding="utf-8")
server_name, local_sid = _lookup_detailed("@MyMod", d, index)
assert_eq(server_name, "@correct")
assert_eq(local_sid, "111")
def _test_lookup_detailed_not_found():
index = {"by_steam_id": {}, "by_name": {}}
with tempfile.TemporaryDirectory() as d:
server_name, local_sid = _lookup_detailed("@Unknown", Path(d), index)
assert server_name is None
def _test_lookup_detailed_returns_sid_even_when_not_on_server():
"""local_sid should be returned even when server lookup fails."""
index = {"by_steam_id": {}, "by_name": {}}
with tempfile.TemporaryDirectory() as d:
d = Path(d)
(d / "meta.cpp").write_text("publishedid = 999;", encoding="utf-8")
server_name, local_sid = _lookup_detailed("@SomeMod", d, index)
assert server_name is None
assert_eq(local_sid, "999")
test("_lookup_detailed: matches by steam_id", _test_lookup_detailed_by_steam_id)
test("_lookup_detailed: falls back to name", _test_lookup_detailed_by_name_fallback)
test("_lookup_detailed: steam_id beats name fallback", _test_lookup_detailed_steam_id_beats_name)
test("_lookup_detailed: not found -> None", _test_lookup_detailed_not_found)
test("_lookup_detailed: returns local_sid even when not on server", _test_lookup_detailed_returns_sid_even_when_not_on_server)
# _resolve_status tests
def _test_resolve_status_ok():
status, col = _resolve_status("@ace", "@ace", None, ok_disk_names={"@ace"})
assert_eq(status, "OK")
assert_eq(col, "@ace")
def _test_resolve_status_mismatch():
status, col = _resolve_status("@ACE3", "@ace3", None, ok_disk_names={"@cba_a3"})
assert_eq(status, "MISMATCH")
assert_eq(col, "@ace3")
def _test_resolve_status_not_found():
status, col = _resolve_status("@Unknown", None, None, ok_disk_names=set())
assert_eq(status, "NOT_ON_SERVER")
def _test_resolve_status_id_collision():
ok = {"@Realistic Ragdoll Physics"}
status, col = _resolve_status(
"@NIArms All in One- ACE Compatibility",
"@Realistic Ragdoll Physics",
"1234567",
ok_disk_names=ok,
)
assert_eq(status, "ID_COLLISION")
assert "1234567" in col # bad steam_id shown in output
def _test_resolve_status_collision_without_sid():
ok = {"@Realistic Ragdoll Physics"}
status, col = _resolve_status(
"@Some Mod", "@Realistic Ragdoll Physics", None, ok_disk_names=ok
)
assert_eq(status, "ID_COLLISION")
test("_resolve_status: OK match", _test_resolve_status_ok)
test("_resolve_status: genuine mismatch", _test_resolve_status_mismatch)
test("_resolve_status: not found", _test_resolve_status_not_found)
test("_resolve_status: ID_COLLISION shows bad steam_id", _test_resolve_status_id_collision)
test("_resolve_status: ID_COLLISION without local sid", _test_resolve_status_collision_without_sid)
# _write_steam_id tests
def _test_write_steam_id_updates_existing():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
meta = d / "meta.cpp"
meta.write_text('publishedid = 111;\nname = "OldMod";\n', encoding="utf-8")
ok, msg = _write_steam_id(d, "999")
assert ok
assert "999" in meta.read_text(encoding="utf-8")
assert "111" not in meta.read_text(encoding="utf-8")
assert 'name = "OldMod"' in meta.read_text(encoding="utf-8") # preserved
def _test_write_steam_id_creates_if_missing():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
ok, msg = _write_steam_id(d, "12345")
assert ok
meta = d / "meta.cpp"
assert meta.exists()
assert "12345" in meta.read_text(encoding="utf-8")
def _test_write_steam_id_appends_if_no_line():
with tempfile.TemporaryDirectory() as d:
d = Path(d)
meta = d / "meta.cpp"
meta.write_text('name = "NoId";\n', encoding="utf-8")
ok, msg = _write_steam_id(d, "777")
assert ok
content = meta.read_text(encoding="utf-8")
assert "777" in content
assert 'name = "NoId"' in content
test("_write_steam_id: updates existing publishedid", _test_write_steam_id_updates_existing)
test("_write_steam_id: creates meta.cpp if missing", _test_write_steam_id_creates_if_missing)
test("_write_steam_id: appends if no publishedid line", _test_write_steam_id_appends_if_no_line)
# _build_comparison_id_map tests
def _test_build_comparison_id_map_reads_json():
comparison = {
"compared_presets": ["A", "B"],
"shared": {"mod_count": 1, "mods": [
{"name": "CBA_A3", "steam_id": "450814997", "url": None, "source": "steam"},
]},
"unique": {
"A": {"mod_count": 1, "mods": [
{"name": "ACE3", "steam_id": "463939057", "url": None, "source": "steam"},
]},
"B": {"mod_count": 0, "mods": []},
},
}
with tempfile.TemporaryDirectory() as d:
cfg_data = {
"server": {"base_url": "x", "username": "u", "password": "p"},
"paths": {"arma_dir": d, "downloads": d,
"modlist_html": d, "modlist_json": d},
}
import json as _json
cfg_file = Path(d) / "config.json"
cfg_file.write_text(_json.dumps(cfg_data), encoding="utf-8")
from arma_modlist_tools.config import load_config as _lc
cfg = _lc(str(cfg_file))
# Write a fake comparison.json
comp_path = Path(d) / "comparison.json"
comp_path.write_text(_json.dumps(comparison), encoding="utf-8")
id_map = _build_comparison_id_map(cfg)
assert_in("cbaa3", id_map)
assert_eq(id_map["cbaa3"][0], "450814997")
assert_in("ace3", id_map)
assert_eq(id_map["ace3"][0], "463939057")
test("_build_comparison_id_map: reads steam_ids from comparison.json", _test_build_comparison_id_map_reads_json)
# ---------------------------------------------------------------------------
# 10. Integration: parse → compare → reporter (offline)
# ---------------------------------------------------------------------------
group("integration: parse → compare → reporter (offline)")
def _test_end_to_end_offline():
"""Parse real HTML files, compare them, build a mock report."""
html_dir = Path(__file__).parent / "modlist_html"
presets = parse_modlist_dir(html_dir)
assert len(presets) >= 2, "Need >=2 HTML presets for integration test"
comparison = compare_presets(*presets)
total = comparison["shared"]["mod_count"] + sum(
d["mod_count"] for d in comparison["unique"].values()
)
assert total > 0, "Comparison must have mods"
# Build report against empty index (all missing)
report_all_missing = build_missing_report(comparison, {"by_steam_id": {}, "by_name": {}})
assert_eq(report_all_missing["missing"], total)
# Build report against full index (all found)
full_by_id = {
mod["steam_id"]: f"https://x.com/@mod{i}/"
for i, mod in enumerate(
comparison["shared"]["mods"]
+ [m for d in comparison["unique"].values() for m in d["mods"]]
)
if mod.get("steam_id")
}
full_by_name = {
_normalize_name(mod["name"]): f"https://x.com/@mod{i}/"
for i, mod in enumerate(
comparison["shared"]["mods"]
+ [m for d in comparison["unique"].values() for m in d["mods"]]
)
if not mod.get("steam_id")
}
report_all_found = build_missing_report(
comparison, {"by_steam_id": full_by_id, "by_name": full_by_name}
)
assert_eq(report_all_found["missing"], 0)
def _test_comparison_json_consistent_with_html():
"""The real comparison.json on disk must match a fresh parse+compare."""
html_dir = Path(__file__).parent / "modlist_html"
json_file = Path(__file__).parent / "modlist_json" / "comparison.json"
if not json_file.exists():
raise AssertionError(f"comparison.json not found: {json_file}")
presets = parse_modlist_dir(html_dir)
fresh = compare_presets(*presets)
on_disk = json.loads(json_file.read_text(encoding="utf-8"))
assert_eq(
sorted(fresh["compared_presets"]),
sorted(on_disk["compared_presets"]),
)
assert_eq(fresh["shared"]["mod_count"], on_disk["shared"]["mod_count"])
for pname in fresh["compared_presets"]:
assert_eq(
fresh["unique"][pname]["mod_count"],
on_disk["unique"][pname]["mod_count"],
f"unique mod count mismatch for {pname}",
)
test("end-to-end offline pipeline (parse → compare → report)", _test_end_to_end_offline)
test("comparison.json on disk matches fresh parse+compare", _test_comparison_json_consistent_with_html)
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
total = _passed + _failed + _skipped
print(f"\n{'='*60}")
print(f" Results: {_passed} passed, {_failed} failed, {_skipped} skipped ({total} total)")
print(f"{'='*60}\n")
if _failed:
sys.exit(1)