- Add auto-detected RPG Maker and generic loose-PNG profiles - Make scanning read-only and patch images through validated transactions - Preserve RPG Maker compatibility with backups and legacy migration - Document the workflow and expand image manager coverage - Enable core RPG Maker dialogue and choice codes by default
231 lines
9.3 KiB
Python
231 lines
9.3 KiB
Python
import tempfile
|
|
import unittest
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from PIL import Image
|
|
|
|
from util.image_manager import (
|
|
PROFILE_GENERIC,
|
|
PROFILE_RPGMAKER_MVMZ,
|
|
copy_plain_assets_to_workspace,
|
|
detect_image_engine,
|
|
make_profile_assets_editable,
|
|
prepare_plain_assets_for_patch,
|
|
prepare_profile_assets_for_patch,
|
|
registered_image_profiles,
|
|
scan_generic_image_assets,
|
|
scan_profile_assets,
|
|
)
|
|
from util.rpgmaker_images import decrypt_image_bytes, encrypt_image_bytes
|
|
|
|
|
|
def png_bytes(color: str) -> bytes:
|
|
output = BytesIO()
|
|
Image.new("RGBA", (10, 8), color).save(output, format="PNG")
|
|
return output.getvalue()
|
|
|
|
|
|
class GenericImageManagerTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.temp.name) / "Game"
|
|
self.images = self.root / "assets" / "ui"
|
|
self.images.mkdir(parents=True)
|
|
self.source = self.images / "menu.png"
|
|
self.source.write_bytes(png_bytes("red"))
|
|
|
|
def tearDown(self):
|
|
self.temp.cleanup()
|
|
|
|
def test_scan_mirrors_game_relative_path_in_workspace(self):
|
|
asset = scan_generic_image_assets(self.root, self.root / "assets")[0]
|
|
|
|
self.assertEqual(asset.engine_id, PROFILE_GENERIC)
|
|
self.assertEqual(asset.asset_id, "assets/ui/menu.png")
|
|
self.assertEqual(
|
|
asset.plain_path,
|
|
self.root / ".dazedtl" / "images" / "assets" / "ui" / "menu.png",
|
|
)
|
|
|
|
def test_make_editable_is_non_destructive_and_does_not_overwrite(self):
|
|
asset = scan_generic_image_assets(self.root, self.root / "assets")[0]
|
|
original = self.source.read_bytes()
|
|
|
|
first = copy_plain_assets_to_workspace(self.root, [asset])
|
|
asset.plain_path.write_bytes(png_bytes("blue"))
|
|
second = copy_plain_assets_to_workspace(self.root, [asset])
|
|
|
|
self.assertEqual(first.completed, 1)
|
|
self.assertEqual(second.skipped, 1)
|
|
self.assertEqual(self.source.read_bytes(), original)
|
|
self.assertEqual(asset.plain_path.read_bytes(), png_bytes("blue"))
|
|
self.assertTrue((self.root / ".dazedtl" / "image_manager" / "manifest.json").is_file())
|
|
|
|
def test_patch_publishes_backup_and_exact_allow_rule(self):
|
|
asset = scan_generic_image_assets(self.root, self.root / "assets")[0]
|
|
original = self.source.read_bytes()
|
|
copy_plain_assets_to_workspace(self.root, [asset])
|
|
translated = png_bytes("green")
|
|
asset.plain_path.write_bytes(translated)
|
|
self.root.joinpath(".gitignore").write_text("*.png\n/.dazedtl/\n", encoding="utf-8")
|
|
|
|
result = prepare_plain_assets_for_patch(self.root, [asset])
|
|
|
|
self.assertEqual(result.completed, 1)
|
|
self.assertEqual(result.errors, [])
|
|
self.assertEqual(self.source.read_bytes(), translated)
|
|
backup = self.root / ".dazedtl" / "image_backups" / "assets/ui/menu.png"
|
|
self.assertEqual(backup.read_bytes(), original)
|
|
self.assertIn(
|
|
"!/assets/ui/menu.png",
|
|
self.root.joinpath(".gitignore").read_text(encoding="utf-8"),
|
|
)
|
|
|
|
def test_patch_rejects_source_changed_after_editable_copy(self):
|
|
asset = scan_generic_image_assets(self.root, self.root / "assets")[0]
|
|
copy_plain_assets_to_workspace(self.root, [asset])
|
|
asset.plain_path.write_bytes(png_bytes("green"))
|
|
external = png_bytes("purple")
|
|
self.source.write_bytes(external)
|
|
|
|
result = prepare_plain_assets_for_patch(self.root, [asset])
|
|
|
|
self.assertEqual(result.completed, 0)
|
|
self.assertIn("runtime image changed", result.errors[0])
|
|
self.assertEqual(self.source.read_bytes(), external)
|
|
|
|
def test_patch_batch_preflight_is_all_or_nothing(self):
|
|
second_source = self.images / "title.png"
|
|
second_source.write_bytes(png_bytes("yellow"))
|
|
first, second = scan_generic_image_assets(self.root, self.root / "assets")
|
|
copy_plain_assets_to_workspace(self.root, [first, second])
|
|
first.plain_path.write_bytes(png_bytes("green"))
|
|
second.plain_path.write_bytes(png_bytes("blue"))
|
|
second_source.write_bytes(png_bytes("purple"))
|
|
first_original = self.source.read_bytes()
|
|
|
|
result = prepare_plain_assets_for_patch(self.root, [first, second])
|
|
|
|
self.assertEqual(result.completed, 0)
|
|
self.assertEqual(self.source.read_bytes(), first_original)
|
|
self.assertIn("runtime image changed", result.errors[0])
|
|
|
|
def test_patch_rolls_back_runtime_if_gitignore_update_fails(self):
|
|
asset = scan_generic_image_assets(self.root, self.root / "assets")[0]
|
|
original = self.source.read_bytes()
|
|
copy_plain_assets_to_workspace(self.root, [asset])
|
|
asset.plain_path.write_bytes(png_bytes("green"))
|
|
|
|
with patch(
|
|
"util.rpgmaker_images.add_patch_exceptions",
|
|
side_effect=OSError("ignore file is read-only"),
|
|
):
|
|
result = prepare_plain_assets_for_patch(self.root, [asset])
|
|
|
|
self.assertEqual(result.completed, 0)
|
|
self.assertEqual(self.source.read_bytes(), original)
|
|
self.assertIn("rolled back", result.errors[0])
|
|
stage = self.root / ".dazedtl" / "image_patch_stage"
|
|
self.assertFalse(stage.exists() and any(stage.iterdir()))
|
|
|
|
def test_patch_rejects_corrupt_editable_png(self):
|
|
asset = scan_generic_image_assets(self.root, self.root / "assets")[0]
|
|
original = self.source.read_bytes()
|
|
copy_plain_assets_to_workspace(self.root, [asset])
|
|
asset.plain_path.write_bytes(b"not a PNG")
|
|
|
|
result = prepare_plain_assets_for_patch(self.root, [asset])
|
|
|
|
self.assertEqual(result.completed, 0)
|
|
self.assertIn("not a valid PNG", result.errors[0])
|
|
self.assertEqual(self.source.read_bytes(), original)
|
|
|
|
def test_case_only_asset_collision_is_rejected(self):
|
|
self.images.joinpath("Menu.png").write_bytes(png_bytes("blue"))
|
|
|
|
with self.assertRaisesRegex(ValueError, "differ only by letter case"):
|
|
scan_profile_assets(PROFILE_GENERIC, self.root, self.root / "assets")
|
|
|
|
def test_generic_root_must_remain_inside_game(self):
|
|
outside = Path(self.temp.name) / "Outside"
|
|
outside.mkdir()
|
|
|
|
with self.assertRaisesRegex(ValueError, "outside the selected game folder"):
|
|
scan_generic_image_assets(self.root, outside)
|
|
|
|
def test_scan_is_read_only(self):
|
|
scan_generic_image_assets(self.root, self.root / "assets")
|
|
|
|
self.assertFalse((self.root / ".dazedtl").exists())
|
|
self.assertFalse((self.root / ".gitignore").exists())
|
|
|
|
|
|
class ImageEngineDetectionTests(unittest.TestCase):
|
|
def test_registry_exposes_rpgmaker_and_generic_capabilities(self):
|
|
profiles = {profile.engine_id: profile for profile in registered_image_profiles()}
|
|
|
|
self.assertTrue(profiles[PROFILE_RPGMAKER_MVMZ].supports_encryption)
|
|
self.assertFalse(profiles[PROFILE_GENERIC].supports_encryption)
|
|
self.assertTrue(profiles[PROFILE_GENERIC].configurable_image_root)
|
|
|
|
def test_detects_rpgmaker_from_system_json_and_img(self):
|
|
with tempfile.TemporaryDirectory() as raw:
|
|
root = Path(raw)
|
|
(root / "data").mkdir()
|
|
(root / "img").mkdir()
|
|
(root / "data" / "System.json").write_text("{}", encoding="utf-8")
|
|
|
|
detection = detect_image_engine(root)
|
|
|
|
self.assertEqual(detection.engine_id, PROFILE_RPGMAKER_MVMZ)
|
|
self.assertEqual(detection.confidence, "high")
|
|
|
|
def test_unknown_layout_falls_back_to_generic_with_suggestion(self):
|
|
with tempfile.TemporaryDirectory() as raw:
|
|
root = Path(raw)
|
|
images = root / "assets" / "images"
|
|
images.mkdir(parents=True)
|
|
images.joinpath("title.png").write_bytes(png_bytes("black"))
|
|
|
|
detection = detect_image_engine(root)
|
|
|
|
self.assertEqual(detection.engine_id, PROFILE_GENERIC)
|
|
self.assertEqual(detection.suggested_image_root, images)
|
|
|
|
|
|
class RPGMakerImageProfileTests(unittest.TestCase):
|
|
def test_profile_preserves_decrypt_edit_reencrypt_workflow(self):
|
|
key = bytes.fromhex("00112233445566778899aabbccddeeff")
|
|
with tempfile.TemporaryDirectory() as raw:
|
|
root = Path(raw)
|
|
image_root = root / "img" / "pictures"
|
|
data_root = root / "data"
|
|
image_root.mkdir(parents=True)
|
|
data_root.mkdir()
|
|
data_root.joinpath("System.json").write_text(
|
|
'{"encryptionKey":"00112233445566778899aabbccddeeff"}',
|
|
encoding="utf-8",
|
|
)
|
|
runtime = image_root / "title.png_"
|
|
runtime.write_bytes(encrypt_image_bytes(png_bytes("red"), key))
|
|
asset = scan_profile_assets(PROFILE_RPGMAKER_MVMZ, root)[0]
|
|
|
|
made = make_profile_assets_editable(
|
|
PROFILE_RPGMAKER_MVMZ, root, [asset], key
|
|
)
|
|
translated = png_bytes("blue")
|
|
asset.plain_path.write_bytes(translated)
|
|
patched = prepare_profile_assets_for_patch(
|
|
PROFILE_RPGMAKER_MVMZ, root, [asset], key
|
|
)
|
|
|
|
self.assertEqual(made.completed, 1)
|
|
self.assertEqual(patched.completed, 1)
|
|
self.assertEqual(patched.errors, [])
|
|
self.assertEqual(decrypt_image_bytes(runtime.read_bytes(), key), translated)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|