Pipeline: parse HTML presets, compare modlists, download from Caddy file server, create junctions/symlinks to Arma 3 Server directory. Includes update/sync flows, missing-mod reporting, OS compat layer, shared config, dep checker, comprehensive test suite (71 tests). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1146 lines
41 KiB
Python
1146 lines
41 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. 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)
|