import os import unittest from pathlib import Path from mhwmm.core import paks from mhwmm.core.deploy import DeployError, DeployMod, Deployer, build_desired, validate_dest from mhwmm.core.layout import classify from tests.helpers import TmpCase, make_game, make_kpka, snapshot, write_tree class DeployBase(TmpCase): def setUp(self): super().setUp() self.game = make_game(self.tmp) self.baseline = paks.capture_baseline(self.game) paks.save_baseline(self.paths.baseline_file, self.baseline) def mod(self, mid: str, files: dict, **kw) -> DeployMod: root = write_tree(self.paths.mods_dir / mid / "files", files) return DeployMod(mid, tuple(classify(root, **kw).entries)) def deployer(self, mode="symlink") -> Deployer: return Deployer(self.game, self.paths, mode) def deploy(self, d: Deployer, mods): return d.apply(d.plan(build_desired(mods, self.baseline))) class RoundTrip(DeployBase): def _roundtrip(self, mode): # a pre-existing unmanaged file that a mod will shadow write_tree(self.game, {"natives/STM/shadowed.txt": "original"}) before = snapshot(self.game) mods = [ self.mod("a", {"natives/STM/a.bin": "A", "natives/STM/shadowed.txt": "mod", "reframework/autorun/a.lua": "print(1)", "a.pak": "P1"}), self.mod("b", {"natives/STM/deep/dir/b.bin": "B", "b.pak": "P2", "sub.sub_000.pak.patch_099.pak": "S"}), ] d = self.deployer(mode) res = self.deploy(d, mods) self.assertGreater(res.added, 0) self.assertTrue((self.game / "natives/STM/deep/dir/b.bin").exists()) self.assertEqual((self.game / "natives/STM/shadowed.txt").read_text(), "mod") self.assertTrue((self.game / "re_chunk_000.pak.patch_016.pak").exists()) self.assertTrue((self.game / "re_chunk_000.pak.patch_017.pak").exists()) self.assertTrue((self.game / "re_chunk_000.pak.sub_000.pak.patch_016.pak").exists()) self.assertNotEqual(before, snapshot(self.game)) d.undeploy_all() self.assertEqual(before, snapshot(self.game)) self.assertEqual(d.manifest()["entries"], {}) def test_roundtrip_symlink(self): self._roundtrip("symlink") def test_roundtrip_hardlink(self): self._roundtrip("hardlink") def test_roundtrip_copy(self): self._roundtrip("copy") def test_incremental_disable_prunes_only_our_dirs(self): (self.game / "natives").mkdir() # pre-existing, not ours before = snapshot(self.game) a = self.mod("a", {"natives/STM/x/a.bin": "A"}) d = self.deployer() self.deploy(d, [a]) self.deploy(d, []) self.assertEqual(before, snapshot(self.game)) self.assertTrue((self.game / "natives").is_dir()) class Conflicts(DeployBase): def test_later_mod_wins_and_reorder_flips(self): a = self.mod("a", {"natives/STM/shared.bin": "A"}) b = self.mod("b", {"natives/STM/Shared.bin": "B"}) # case differs des = build_desired([a, b], self.baseline) self.assertEqual(len(des.conflicts), 1) self.assertEqual(des.conflicts[0].winner, "b") self.assertEqual(des.conflicts[0].losers, ["a"]) des = build_desired([b, a], self.baseline) self.assertEqual(des.conflicts[0].winner, "a") d = self.deployer() self.deploy(d, [a, b]) self.assertEqual((self.game / "natives/STM/Shared.bin").read_text(), "B") self.deploy(d, [b, a]) self.assertEqual((self.game / "natives/STM/shared.bin").read_text(), "A") self.assertFalse((self.game / "natives/STM/Shared.bin").exists()) def test_conflicts_span_pak_and_loose(self): loose = "natives/STM/player/mod.bin" pak_a = make_kpka([loose, "natives/stm/other.bin"]) pak_b = make_kpka(["natives/stm/other.bin"]) a = self.mod("a", {"a.pak": pak_a}) b = self.mod("b", {"b.pak": pak_b}) c = self.mod("c", {loose: "x"}) des = build_desired([a, b, c], self.baseline) kinds = {(x.kind, tuple(x.mods)): x for x in des.conflicts} self.assertIn(("pak-pak", ("a", "b")), kinds) self.assertEqual(kinds[("pak-pak", ("a", "b"))].winner, "b") pl = kinds[("pak-loose", ("a", "c"))] self.assertEqual(pl.winner, "c") # loose loader beats PAK contents class Safety(DeployBase): def test_modified_file_is_never_deleted(self): a = self.mod("a", {"natives/STM/a.bin": "A"}) d = self.deployer("copy") self.deploy(d, [a]) target = self.game / "natives/STM/a.bin" target.write_text("user edited") plan = d.plan(build_desired([], self.baseline)) self.assertTrue(plan.warnings) d.apply(plan) self.assertEqual(target.read_text(), "user edited") self.assertEqual(d.manifest()["entries"], {}) def test_foreign_pak_is_never_deleted(self): d = self.deployer() stranger = self.game / "re_chunk_000.pak.patch_016.pak" stranger.write_bytes(b"not ours") self.deploy(d, []) d.undeploy_all() self.assertEqual(stranger.read_bytes(), b"not ours") def test_reframework_data_never_managed(self): m = self.mod("a", {"reframework/data/settings.json": "x", "reframework/autorun/a.lua": "y"}) self.assertTrue(all("data" not in e.dest for e in m.entries)) self.assertIsNotNone(validate_dest("reframework/data/x.json")) self.assertIsNotNone(validate_dest("../evil")) self.assertIsNotNone(validate_dest("/etc/passwd")) self.assertIsNotNone(validate_dest("natives/../../x")) self.assertIsNotNone(validate_dest("MonsterHunterWilds.exe")) self.assertIsNone(validate_dest("natives/STM/a.bin")) def test_rollback_on_midway_failure(self): good = self.mod("a", {"natives/STM/a.bin": "A", "natives/STM/z.bin": "Z"}) d = self.deployer() plan = d.plan(build_desired([good], self.baseline)) os.unlink(good.entries[-1].src) # last add will fail: source vanished before = snapshot(self.game) with self.assertRaises(DeployError): d.apply(plan) self.assertEqual(before, snapshot(self.game)) self.assertEqual(d.manifest()["entries"], {}) def test_replacing_keeps_original_backup(self): write_tree(self.game, {"natives/STM/s.txt": "orig"}) before = snapshot(self.game) d = self.deployer() self.deploy(d, [self.mod("a", {"natives/STM/s.txt": "A"})]) self.deploy(d, [self.mod("a", {"natives/STM/s.txt": "A"}), self.mod("b", {"natives/STM/s.txt": "B"})]) self.assertEqual((self.game / "natives/STM/s.txt").read_text(), "B") d.undeploy_all() self.assertEqual(before, snapshot(self.game)) def test_dll_choice(self): root = write_tree(self.paths.mods_dir / "d" / "files", {"thing.dll": "x"}) self.assertEqual(len(classify(root).ambiguous), 1) self.assertEqual(classify(root, "plugin").entries[0].dest, "reframework/plugins/thing.dll") self.assertEqual(classify(root, "root").entries[0].dest, "thing.dll") self.assertTrue(classify(root, "skip").empty) class PakUpdate(DeployBase): def test_slots_start_above_ceiling_in_load_order(self): a = self.mod("a", {"a1.pak": "1", "a2.pak": "2"}) b = self.mod("b", {"b.pak": "3"}) slots = paks.allocate([(m.id, list(m.entries)) for m in (a, b)], self.baseline) self.assertEqual([(s.mod_id, s.number) for s in slots], [("a", 16), ("a", 17), ("b", 18)]) def test_title_update_reslots(self): d = self.deployer() a = self.mod("a", {"a.pak": "AAA", "natives/STM/a.bin": "A"}) self.deploy(d, [a]) self.assertEqual(d.owned_paks(), {"re_chunk_000.pak.patch_016.pak"}) self.assertFalse(paks.detect_update(self.game, self.baseline, d.owned_paks()).updated) # Steam ships patch_016 over our symlink and patch_017 on the sub chain (self.game / "re_chunk_000.pak.patch_016.pak").unlink() (self.game / "re_chunk_000.pak.patch_016.pak").write_bytes(b"vanilla-16") (self.game / "re_chunk_000.pak.sub_000.pak.patch_016.pak").write_bytes(b"vanilla-sub-16") rep = paks.detect_update(self.game, self.baseline, d.owned_paks()) self.assertTrue(rep.updated) d.apply(d.plan(d.desired_from_manifest(exclude_kind="pak"))) self.assertEqual((self.game / "re_chunk_000.pak.patch_016.pak").read_bytes(), b"vanilla-16") self.assertTrue((self.game / "natives/STM/a.bin").is_symlink()) # loose files untouched self.baseline = paks.capture_baseline(self.game, d.owned_paks()) paks.save_baseline(self.paths.baseline_file, self.baseline) self.assertEqual(self.baseline.ceiling("main"), 16) self.assertEqual(self.baseline.ceiling("sub"), 16) self.deploy(d, [a]) self.assertTrue((self.game / "re_chunk_000.pak.patch_017.pak").is_symlink()) self.assertEqual(d.owned_paks(), {"re_chunk_000.pak.patch_017.pak"}) d.undeploy_all() self.assertEqual((self.game / "re_chunk_000.pak.patch_016.pak").read_bytes(), b"vanilla-16") def test_synthetic_patch_016_from_outside_is_detected(self): d = self.deployer() (self.game / "re_chunk_000.pak.patch_016.pak").write_bytes(b"steam") self.assertTrue(paks.detect_update(self.game, self.baseline, d.owned_paks()).updated) def test_baseline_ceiling(self): self.assertEqual(self.baseline.ceiling("main"), 15) self.assertEqual(self.baseline.ceiling("sub"), 15) if __name__ == "__main__": unittest.main()