Includes earlier uncommitted work: Tomie and Unity adapters, benchmark, structured output, call metrics. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
149 lines
7.6 KiB
Python
149 lines
7.6 KiB
Python
import base64
|
||
import hashlib
|
||
import json
|
||
from pathlib import Path
|
||
import struct
|
||
import tempfile
|
||
import unittest
|
||
from unittest.mock import patch
|
||
|
||
from local_translator.benchmark import write_json
|
||
from local_translator.tomie import STATE, append_managed_records, atomic_copy, digest, inside, install, original_sources, patch_catalog, patch_lock, restore, rewrite_dictionary
|
||
|
||
|
||
class DictionaryTests(unittest.TestCase):
|
||
def test_missing_runtime_graphics_labels_are_added_as_managed_records(self):
|
||
key = "Naninovel.UI.GameSettingsGraphicsDropdown.GraphicOption5"
|
||
result = append_managed_records("Key: Value\r\n", {key: "Очень высокое"})
|
||
self.assertEqual(result, f"Key: Value\r\n{key}: Очень высокое\r\n")
|
||
with self.assertRaises(ValueError):
|
||
append_managed_records(result, {key: "Duplicate"})
|
||
with self.assertRaises(ValueError):
|
||
append_managed_records("Key: Value", {key: "A\nB"})
|
||
|
||
def test_preserves_keys_whitespace_comments_and_crlf(self):
|
||
source = '; comment\r\nMenu.Load: LOAD\r\nOther: value: extra\r\n'
|
||
result = rewrite_dictionary(source, {"Menu.Load": "ЗАГРУЗИТЬ"})
|
||
self.assertEqual(result, '; comment\r\nMenu.Load: ЗАГРУЗИТЬ\r\nOther: value: extra\r\n')
|
||
|
||
def test_unknown_duplicate_and_multiline_values_rejected(self):
|
||
for source, replacements in [("Key: A", {"Other": "B"}), ("Key: A\nKey: B", {"Key": "В"}), ("Key: A", {"Key": "А\nБ"})]:
|
||
with self.assertRaises(ValueError):
|
||
rewrite_dictionary(source, replacements)
|
||
|
||
def test_catalog_offsets_preserved_and_crc_actually_updated(self):
|
||
old_hash, new_hash = "a" * 32, "b" * 32
|
||
record = json.dumps({"m_Hash": old_hash, "m_Crc": 2659166264, "m_BundleSize": 37229646}, separators=(",", ":")).encode("utf-16-le")
|
||
payload = b"header" + struct.pack("<I", len(record)) + record + b"unrelated-second-record"
|
||
document = {"m_ExtraDataString": base64.b64encode(payload).decode(), "other": "keep"}
|
||
result, before, after = patch_catalog(document, old_hash, 12345, 36999999, new_hash)
|
||
raw = base64.b64decode(result["m_ExtraDataString"])
|
||
self.assertEqual(len(raw), len(payload))
|
||
self.assertTrue(raw.endswith(b"unrelated-second-record"))
|
||
self.assertEqual(before["m_Crc"], 2659166264)
|
||
self.assertEqual(after["m_Crc"], 12345)
|
||
self.assertEqual(after["m_Hash"], new_hash)
|
||
self.assertEqual(result["other"], "keep")
|
||
with self.assertRaises(ValueError):
|
||
patch_catalog(document, old_hash, 12345, 999999999, new_hash)
|
||
|
||
|
||
class InstallerTests(unittest.TestCase):
|
||
def setUp(self):
|
||
self.temp = tempfile.TemporaryDirectory()
|
||
self.root = Path(self.temp.name)
|
||
self.game = self.root / "game"
|
||
self.package = self.root / "package"
|
||
self.game.mkdir()
|
||
(self.game / "TomieWGM.exe").write_bytes(b"fake executable, never run")
|
||
self.records = []
|
||
for name in ("data.unity3d", "StreamingAssets/aa/catalog.json"):
|
||
relative = Path("TomieWGM_Data") / name
|
||
original = self.game / relative
|
||
payload = self.package / "payload" / relative
|
||
original.parent.mkdir(parents=True, exist_ok=True)
|
||
payload.parent.mkdir(parents=True, exist_ok=True)
|
||
original.write_bytes(("original " + name).encode())
|
||
payload.write_bytes(("translated " + name).encode())
|
||
self.records.append({"path": relative.as_posix(), "before": digest(original), "after": digest(payload)})
|
||
write_json(self.package / "manifest.json", {"id": "patch-1", "files": self.records})
|
||
|
||
def tearDown(self):
|
||
self.temp.cleanup()
|
||
|
||
def test_install_is_idempotent_and_restore_is_byte_exact(self):
|
||
install(self.package, self.game, lambda: False)
|
||
state = json.loads((self.game / STATE).read_text(encoding="utf-8"))
|
||
self.assertEqual(state["phase"], "installed")
|
||
for row in state["files"]:
|
||
self.assertEqual(digest(self.game / row["path"]), row["after"])
|
||
self.assertEqual(digest(self.game / row["backup"]), row["before"])
|
||
install(self.package, self.game, lambda: False)
|
||
restore(self.game, lambda: False)
|
||
for row in self.records:
|
||
self.assertEqual(digest(self.game / row["path"]), row["before"])
|
||
self.assertEqual(json.loads((self.game / STATE).read_text())["phase"], "restored")
|
||
|
||
def test_running_game_blocks_both_actions(self):
|
||
with self.assertRaises(RuntimeError):
|
||
install(self.package, self.game, lambda: True)
|
||
with self.assertRaises(RuntimeError):
|
||
restore(self.game, lambda: True)
|
||
self.assertFalse((self.game / STATE).exists())
|
||
|
||
def test_foreign_original_and_damaged_payload_are_rejected_before_writes(self):
|
||
target = self.game / self.records[-1]["path"]
|
||
target.write_bytes(b"foreign mod")
|
||
with self.assertRaises(ValueError):
|
||
install(self.package, self.game, lambda: False)
|
||
self.assertEqual(digest(self.game / self.records[0]["path"]), self.records[0]["before"])
|
||
target.write_bytes(b"original StreamingAssets/aa/catalog.json")
|
||
(self.package / "payload" / self.records[-1]["path"]).write_bytes(b"tampered")
|
||
with self.assertRaises(ValueError):
|
||
install(self.package, self.game, lambda: False)
|
||
self.assertFalse((self.game / STATE).exists())
|
||
|
||
def test_partial_install_failure_rolls_back_already_replaced_files(self):
|
||
def fail_second(source, target, expected):
|
||
if Path(source).resolve().is_relative_to((self.package / "payload").resolve()) and Path(target).name == "catalog.json":
|
||
raise OSError("simulated disk failure")
|
||
return atomic_copy(source, target, expected)
|
||
with patch("local_translator.tomie.atomic_copy", fail_second), self.assertRaises(OSError):
|
||
install(self.package, self.game, lambda: False)
|
||
self.assertEqual(json.loads((self.game / STATE).read_text())["phase"], "restored")
|
||
for row in self.records:
|
||
self.assertEqual(digest(self.game / row["path"]), row["before"])
|
||
|
||
def test_restore_refuses_to_overwrite_new_foreign_changes(self):
|
||
install(self.package, self.game, lambda: False)
|
||
target = self.game / self.records[0]["path"]
|
||
target.write_bytes(b"new unrelated mod")
|
||
with self.assertRaises(ValueError):
|
||
restore(self.game, lambda: False)
|
||
self.assertEqual(target.read_bytes(), b"new unrelated mod")
|
||
|
||
def test_paths_and_concurrent_installers_are_guarded(self):
|
||
for relative in ("../outside", str(self.root.resolve())):
|
||
with self.assertRaises(ValueError):
|
||
inside(self.game, relative)
|
||
with patch_lock(self.game):
|
||
with self.assertRaises(RuntimeError):
|
||
with patch_lock(self.game):
|
||
self.fail("Two installers acquired one lock")
|
||
|
||
def test_rebuild_uses_verified_original_backups_without_changing_installed_game(self):
|
||
install(self.package, self.game, lambda: False)
|
||
inputs = [self.game.resolve() / row["path"] for row in self.records]
|
||
sources, live_hashes = original_sources(self.game.resolve(), inputs)
|
||
for row, path in zip(self.records, inputs):
|
||
self.assertEqual(digest(sources[path]), row["before"])
|
||
self.assertEqual(live_hashes[path], row["after"])
|
||
self.assertEqual(digest(path), row["after"])
|
||
inputs[0].write_bytes(b"foreign edit")
|
||
with self.assertRaises(ValueError):
|
||
original_sources(self.game.resolve(), inputs)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|