"""Unit tests for packwiz_to_sms.convert + CLI.""" from __future__ import annotations import json import subprocess import sys import zipfile from pathlib import Path import pytest ROOT = Path(__file__).resolve().parent.parent FIXTURE = Path(__file__).resolve().parent / "fixture-pack" SCRIPT = ROOT / "packwiz_to_sms.py" sys.path.insert(0, str(ROOT)) import packwiz_to_sms as p2s # noqa: E402 def _by_name(sync: list[dict]) -> dict[str, dict]: return {e["name"]: e for e in sync} def test_convert_emits_client_and_both_only() -> None: r = p2s.convert(FIXTURE) names = {e["name"] for e in r.manifest["sync"]} # client + both kept assert "Sodium" in names assert "Fabric API" in names # server-only dropped assert "Server Side Tool" not in names # CurseForge metadata-mode dropped (no URL) assert "CF Only Mod" not in names # resourcepack + shader kept assert "Server Branding" in names assert "Complementary Shaders" in names def test_convert_skip_reasons() -> None: r = p2s.convert(FIXTURE) assert "Server Side Tool" in r.skipped_server_only assert "CF Only Mod" in r.skipped_no_url assert r.skipped_unknown_type == [] def test_convert_content_types() -> None: r = p2s.convert(FIXTURE) entries = _by_name(r.manifest["sync"]) assert entries["Sodium"]["type"] == "mod" assert entries["Fabric API"]["type"] == "mod" assert entries["Server Branding"]["type"] == "resourcepack" assert entries["Complementary Shaders"]["type"] == "shader" def test_convert_url_preserved_verbatim() -> None: r = p2s.convert(FIXTURE) sodium = _by_name(r.manifest["sync"])["Sodium"] assert sodium["url"] == ( "https://cdn.modrinth.com/data/AANobbMI/versions/EoNKHoLH/" "sodium-fabric-0.6.5%2Bmc1.21.1.jar" ) def test_convert_version_extracted_from_filename() -> None: r = p2s.convert(FIXTURE) entries = _by_name(r.manifest["sync"]) assert entries["Sodium"]["version"] == "0.6.5+mc1.21.1" assert entries["Fabric API"]["version"] == "0.110.5+1.21.1" # Zip artifacts (resourcepacks, shaders) — same extractor handles them. assert entries["Server Branding"]["version"] == "0.3" assert entries["Complementary Shaders"]["version"] == "r5.5" def test_convert_lists_non_mod_files() -> None: r = p2s.convert(FIXTURE) files = {str(p) for p in r.non_mod_files} assert "config/voicechat-client.properties" in files assert "options.txt" in files def test_convert_top_level_shape() -> None: r = p2s.convert(FIXTURE) assert r.manifest["sync_version"] == 3 assert isinstance(r.manifest["sync"], list) assert r.manifest["_generator"]["tool"] == "packwiz-to-sms" assert r.manifest["_generator"]["pack_name"] == "Test Pack" assert r.manifest["_generator"]["pack_version"] == "0.1.0" def test_bundle_non_mods(tmp_path: Path) -> None: r = p2s.convert(FIXTURE) out = tmp_path / "overrides.zip" p2s.bundle_non_mods(FIXTURE, r.non_mod_files, out) with zipfile.ZipFile(out) as zf: names = set(zf.namelist()) assert "config/voicechat-client.properties" in names assert "options.txt" in names def test_bundle_entry_appended() -> None: r = p2s.convert(FIXTURE) n_before = len(r.manifest["sync"]) p2s.add_bundle_entry(r.manifest, "https://example.com/overrides.zip", "0.1.0") assert len(r.manifest["sync"]) == n_before + 1 bundle = r.manifest["sync"][-1] assert bundle["type"] == "packed" assert bundle["directory"] == "." assert bundle["url"] == "https://example.com/overrides.zip" def test_cli_writes_to_stdout(tmp_path: Path) -> None: proc = subprocess.run( [sys.executable, str(SCRIPT), str(FIXTURE), "--quiet"], capture_output=True, text=True, check=True, ) payload = json.loads(proc.stdout) assert payload["sync_version"] == 3 assert any(e["name"] == "Sodium" for e in payload["sync"]) def test_cli_writes_to_output_flag(tmp_path: Path) -> None: out = tmp_path / "manifest.json" subprocess.run( [sys.executable, str(SCRIPT), str(FIXTURE), "--quiet", "-o", str(out)], check=True, ) payload = json.loads(out.read_text()) assert payload["sync_version"] == 3 def test_cli_bundle_requires_url(tmp_path: Path) -> None: proc = subprocess.run( [sys.executable, str(SCRIPT), str(FIXTURE), "--bundle-non-mods", str(tmp_path / "x.zip"), "--quiet"], capture_output=True, text=True, ) assert proc.returncode == 2 assert "--bundle-url" in proc.stderr def test_cli_full_bundle_pipeline(tmp_path: Path) -> None: bundle = tmp_path / "overrides.zip" out = tmp_path / "manifest.json" subprocess.run( [sys.executable, str(SCRIPT), str(FIXTURE), "--quiet", "-o", str(out), "--bundle-non-mods", str(bundle), "--bundle-url", "https://example.com/overrides.zip"], check=True, ) payload = json.loads(out.read_text()) bundle_entry = next( (e for e in payload["sync"] if e.get("type") == "packed"), None ) assert bundle_entry is not None assert bundle_entry["url"] == "https://example.com/overrides.zip" assert bundle.is_file() def test_cli_missing_pack_toml(tmp_path: Path) -> None: proc = subprocess.run( [sys.executable, str(SCRIPT), str(tmp_path), "--quiet"], capture_output=True, text=True, ) assert proc.returncode == 2 assert "pack.toml" in proc.stderr def test_diff_removals_empty_when_no_changes() -> None: """No removals when current sync covers everything in previous.""" prev = [{"name": "Sodium", "type": "mod"}, {"name": "Fabric API", "type": "mod"}] cur = [{"name": "Sodium", "type": "mod"}, {"name": "Fabric API", "type": "mod"}] assert p2s.diff_removals(prev, cur) == [] def test_diff_removals_emits_remove_for_dropped_mod() -> None: """Mod present previously but not currently → modify[].type=remove entry.""" prev = [{"name": "Sodium", "type": "mod"}, {"name": "Iris", "type": "mod"}] cur = [{"name": "Sodium", "type": "mod"}] rm = p2s.diff_removals(prev, cur) assert len(rm) == 1 assert rm[0]["type"] == "remove" assert rm[0]["path"] == "." # pattern targets simple-mod-sync's on-disk naming assert rm[0]["pattern"] == r"^mods/Iris-.*\.jar$" def test_diff_removals_sanitizes_name() -> None: """Name with spaces and symbols sanitized to match simple-mod-sync's filename.""" prev = [{"name": "Fabric API (Old)", "type": "mod"}] cur: list[dict] = [] rm = p2s.diff_removals(prev, cur) # sanitize() strips space, '(', ')' assert rm[0]["pattern"] == r"^mods/FabricAPIOld-.*\.jar$" def test_diff_removals_per_type_paths_and_extensions() -> None: """Each content type targets the right directory + extension.""" prev = [ {"name": "Branding", "type": "resourcepack"}, {"name": "Iris", "type": "shader"}, {"name": "Worldgen", "type": "datapack"}, ] rm = p2s.diff_removals(prev, []) patterns = {r["pattern"] for r in rm} assert patterns == { r"^resourcepacks/Branding-.*\.zip$", r"^shaderpacks/Iris-.*\.zip$", r"^datapacks/Worldgen-.*\.zip$", } def test_convert_with_previous_manifest_adds_modify_section() -> None: """When --previous-manifest is supplied to convert(), modify[] appears in output for any name that disappeared.""" previous = { "sync_version": 3, "sync": [ {"name": "Sodium", "type": "mod", "url": "x", "version": "1"}, {"name": "Iris", "type": "mod", "url": "y", "version": "1"}, {"name": "Some Removed Mod", "type": "mod", "url": "z", "version": "1"}, ], } r = p2s.convert(FIXTURE, previous_manifest=previous) assert "modify" in r.manifest patterns = {e["pattern"] for e in r.manifest["modify"]} # Fixture has Sodium + Fabric API (both kept under "mod"), so Iris + Some Removed Mod # should both be removed. (Iris in fixture is type=shader under "Complementary Shaders", # so the mod-keyed Iris from previous is genuinely gone.) assert r"^mods/Iris-.*\.jar$" in patterns assert r"^mods/SomeRemovedMod-.*\.jar$" in patterns def test_convert_without_previous_manifest_omits_modify() -> None: r = p2s.convert(FIXTURE) assert "modify" not in r.manifest assert r.removed_entries == [] def test_cli_previous_manifest_flag(tmp_path: Path) -> None: """End-to-end CLI with --previous-manifest.""" prev = tmp_path / "old.json" prev.write_text(json.dumps({ "sync_version": 3, "sync": [ {"name": "Sodium", "type": "mod", "url": "x", "version": "1"}, {"name": "GoneAway", "type": "mod", "url": "y", "version": "1"}, ], })) proc = subprocess.run( [sys.executable, str(SCRIPT), str(FIXTURE), "--quiet", "--previous-manifest", str(prev)], capture_output=True, text=True, check=True, ) payload = json.loads(proc.stdout) assert "modify" in payload patterns = {e["pattern"] for e in payload["modify"]} assert r"^mods/GoneAway-.*\.jar$" in patterns def test_cli_previous_manifest_missing_file_warns(tmp_path: Path) -> None: """Non-existent previous-manifest is a warning, not an error — first run case.""" nonexistent = tmp_path / "never-written.json" proc = subprocess.run( [sys.executable, str(SCRIPT), str(FIXTURE), "--previous-manifest", str(nonexistent)], capture_output=True, text=True, check=True, ) # Should succeed, emit manifest with no modify section, and warn on stderr. payload = json.loads(proc.stdout) assert "modify" not in payload assert "does not exist" in proc.stderr def test_convert_against_real_packwiz_example_pack(tmp_path: Path) -> None: """Integration: clone the upstream packwiz-example-pack and convert it. Skipped if no network / git unavailable. Verifies the tool works on a real pack, not just our hand-rolled fixture. """ pack_root = tmp_path / "example-pack" try: subprocess.run( ["git", "clone", "--depth", "1", "--branch", "v1", "https://github.com/packwiz/packwiz-example-pack.git", str(pack_root)], check=True, capture_output=True, timeout=30, ) except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): pytest.skip("network/git unavailable") r = p2s.convert(pack_root) assert r.manifest["sync_version"] == 3 # Example pack has at least two mods we recognize names = {e["name"] for e in r.manifest["sync"]} assert any("Borderless" in n for n in names) # All entries have a URL assert all(e.get("url") for e in r.manifest["sync"])