DazedTL/tests/test_image_manager.py
DazedAnon ef7a3f8235 feat(images): add engine-aware image management
- 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
2026-07-26 12:56:46 -05:00

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()