438b1f7c65
Adds removal-detection: when --previous-manifest <path> is given, the converter diffs the previous publish against current packwiz state and emits modify[].type=remove entries for mods/resourcepacks/etc that disappeared, using simple-mod-sync's on-disk naming convention as the regex pattern. Reverse-engineered from upstream source: - simple-mod-sync writes <sanitized_name>-<sanitized_version>.<ext> - StringUtils.sanitize strips [^a-zA-Z0-9.\-_] - GetOlderVersion() finds files starting with <name>- and auto-deletes on version bumps. So version upgrades need no converter handling; only full removals do. 8 new tests including end-to-end CLI verification with a synthetic previous manifest. 23/23 pass.
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""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"])
|