#!/usr/bin/env python3 """Integration tests for _original source preservation in rpgmakermvmz searchCodes.""" from __future__ import annotations import copy import json import os import re import sys import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] os.chdir(ROOT) sys.path.insert(0, str(ROOT)) import modules.rpgmakermvmz as mvmz # noqa: E402 LANGREGEX = mvmz.LANGREGEX def _mock_translate(text, history, batch=False): def tr(s): if not isinstance(s, str): return s m = re.match(r"^(\[[^\]]+\]:\s?)", s) if m: return m.group(1) + "EN_TRANSLATED" return "EN_TRANSLATED" if isinstance(text, list): return [[tr(t) for t in text], [0, 0]] return [tr(text), [0, 0]] def _mock_speaker(name): return [f"Speaker_{name}", [0, 0]] FIXTURE_MAP = ROOT / "tests" / "fixtures" / "Map_original_fixture.json" FIXTURE_MANIFEST = ROOT / "tests" / "fixtures" / "Map_original_fixture_manifest.json" CASE_MARKER_RE = re.compile(r"# CASE:(\S+)") def _load_fixture_page(): data = json.loads(FIXTURE_MAP.read_text(encoding="utf-8-sig")) event = next(e for e in data["events"] if e and e.get("id") == 1) return {"list": copy.deepcopy(event["pages"][0]["list"])} def _case_commands(page_list): """Map manifest case id -> command immediately following its 108 marker.""" cases = {} pending = None for cmd in page_list: if not cmd: continue if cmd.get("code") == 108: m = CASE_MARKER_RE.search(str(cmd.get("parameters", [""])[0])) pending = m.group(1) if m else None continue if pending: cases[pending] = cmd pending = None return cases def _load_fixture_manifest(): return json.loads(FIXTURE_MANIFEST.read_text(encoding="utf-8")) def _load_map_excerpt(): """Hand-authored page mirroring a 101+401 dialogue block, a 102 choice, and synthetic 101/401/122 commands. Fully self-contained (no files/ dependency).""" # 101 message box (4 params, no speaker name) + 401 dialogue lines. real = [ {"code": 101, "indent": 0, "parameters": ["", 0, 0, 2]}, {"code": 401, "indent": 0, "parameters": ["これは本物のセリフです。"]}, {"code": 401, "indent": 0, "parameters": ["二行目のテキストです。"]}, ] # 102 choice command: parameters[0] is the list of Japanese choice strings. choice_cmd = {"code": 102, "indent": 0, "parameters": [["買う", "売る", "やめる"], -1, 0, 2, 0]} synthetic = [ {"code": 101, "indent": 0, "parameters": ["", 0, 0, 2, "\\C[2]アリス\\C[0]"]}, {"code": 401, "indent": 0, "parameters": ["こんにちは、世界。"]}, { "code": 122, "indent": 0, "parameters": [101, 101, 0, 0, "`変数テスト`"], }, ] return {"list": real + [choice_cmd] + synthetic} def _resolve_case_command(page_list, entry, marked_cases=None): """Find manifest case command by CASE marker or by code + expected _original.""" marked_cases = marked_cases if marked_cases is not None else _case_commands(page_list) cid = entry["id"] cmd = marked_cases.get(cid) if cmd is not None and cmd.get("code") == entry.get("code"): return cmd exp = entry.get("expected_original") code = entry.get("code") if code is not None and exp is not None: for candidate in page_list: if candidate and candidate.get("code") == code and candidate.get("_original") == exp: return candidate return cmd def _run_search_codes(page, *, preserve_original=True, speaker_fn=None): """Full Pass 1 -> mock translate -> Pass 2 cycle.""" captured = [] def translate(text, history, batch=False): captured.append(copy.deepcopy(text)) return _mock_translate(text, history, batch) def speaker(name): return speaker_fn(name) if speaker_fn is not None else _mock_speaker(name) orig_t = mvmz.translateAI orig_s = mvmz.getSpeaker orig_122 = mvmz.CODE122 orig_408 = mvmz.CODE408 orig_101 = mvmz.CODE101 orig_401 = mvmz.CODE401 orig_405 = mvmz.CODE405 orig_102 = mvmz.CODE102 orig_preserve = mvmz.PRESERVEORIGINAL mvmz.translateAI = translate mvmz.getSpeaker = speaker mvmz.CODE122 = True mvmz.CODE408 = True mvmz.CODE101 = True mvmz.CODE401 = True mvmz.CODE405 = True mvmz.CODE102 = True mvmz.PRESERVEORIGINAL = preserve_original try: page_copy = copy.deepcopy(page) mvmz.searchCodes(page_copy, None, [], "TestMap.json") return page_copy, captured finally: mvmz.translateAI = orig_t mvmz.getSpeaker = orig_s mvmz.CODE122 = orig_122 mvmz.CODE408 = orig_408 mvmz.CODE101 = orig_101 mvmz.CODE401 = orig_401 mvmz.CODE405 = orig_405 mvmz.CODE102 = orig_102 mvmz.PRESERVEORIGINAL = orig_preserve def _find_commands(page, code): return [cmd for cmd in page["list"] if cmd and cmd.get("code") == code] def _has_japanese(s: str) -> bool: return bool(re.search(LANGREGEX, s or "")) class TestMVMZSourceOriginal(unittest.TestCase): def test_first_pass_writes_original(self): page, _ = _run_search_codes(_load_map_excerpt()) cmds401 = _find_commands(page, 401) with_orig = [c for c in cmds401 if c.get("_original")] self.assertGreater(len(with_orig), 0, "401 dialogue should have _original") for c in with_orig: self.assertTrue(_has_japanese(c["_original"])) self.assertNotEqual(c["parameters"][0], c["_original"]) c102 = _find_commands(page, 102)[0] self.assertIsInstance(c102.get("_original"), list) self.assertEqual(len(c102["_original"]), len(c102["parameters"][0])) for i, orig in enumerate(c102["_original"]): if orig: self.assertTrue(_has_japanese(orig), f"choice {i} _original should be Japanese") self.assertNotEqual(c102["parameters"][0][i], orig) c101 = next(c for c in _find_commands(page, 101) if len(c.get("parameters", [])) > 4) self.assertIn("_original", c101) self.assertIn("アリス", c101["_original"]) self.assertIn("\\C[2]", c101["_original"]) self.assertIn("Speaker_", c101["parameters"][4]) c122 = _find_commands(page, 122)[0] self.assertEqual(c122["_original"], "変数テスト") self.assertIn("EN_TRANSLATED", c122["parameters"][4]) def test_101_display_brackets_do_not_leak_into_401_dialogue(self): speakers_seen = [] def speaker(name): speakers_seen.append(name) return ["Game Description", [0, 0]] page = { "list": [ { "code": 101, "indent": 0, "parameters": ["", 0, 0, 2, "【[Game Description]】"], "_original": "【ゲーム説明】", }, { "code": 401, "indent": 0, "parameters": ["ここから本編スタートとなります。"], }, ] } translated, captured = _run_search_codes(page, speaker_fn=speaker) cmd101, cmd401 = translated["list"] self.assertTrue(speakers_seen) self.assertEqual(set(speakers_seen), {"ゲーム説明"}) self.assertEqual(cmd101["parameters"][4], "【Game Description】") self.assertEqual(cmd401["parameters"][0], "EN_TRANSLATED") self.assertNotIn("Game Description", cmd401["parameters"][0]) self.assertTrue( any( isinstance(item, str) and item.startswith("[Game Description]: ") for payload in captured for item in (payload if isinstance(payload, list) else [payload]) ) ) def test_rerun_uses_original_not_display_text(self): page1, captured1 = _run_search_codes(_load_map_excerpt()) originals_snapshot = json.dumps( {i: cmd.get("_original") for i, cmd in enumerate(page1["list"]) if cmd}, ensure_ascii=False, ) page2, captured2 = _run_search_codes(page1) originals_after = json.dumps( {i: cmd.get("_original") for i, cmd in enumerate(page2["list"]) if cmd}, ensure_ascii=False, ) self.assertEqual(originals_snapshot, originals_after, "_original must not change on re-run") # Every batch sent to translateAI on re-run should still contain Japanese for payload in captured2: items = payload if isinstance(payload, list) else [payload] for item in items: if not isinstance(item, str): continue if item == "EN_TRANSLATED": continue if _has_japanese(item): continue if re.match(r"^\[.+?\]:\s*EN_TRANSLATED$", item): continue self.fail(f"Re-run sent non-Japanese to translateAI: {item!r}") def test_micro_page_401_original(self): """Translate a tiny 3x401 dialogue slice and confirm _original capture.""" micro = { "list": [ {"code": 401, "indent": 0, "parameters": ["一行目のセリフ。"]}, {"code": 401, "indent": 0, "parameters": ["二行目のセリフ。"]}, {"code": 401, "indent": 0, "parameters": ["三行目のセリフ。"]}, ] } page, _ = _run_search_codes(micro) c401 = _find_commands(page, 401) self.assertGreaterEqual(len(c401), 1) with_orig = [c for c in c401 if c.get("_original")] self.assertGreaterEqual(len(with_orig), 1) for c in with_orig: self.assertTrue(_has_japanese(c["_original"])) def test_leading_format_codes_not_duplicated(self): """Leading \\F/\\AA/\\M codes must be restored once, including on re-run.""" src = "\\F4[2]\\AA[4]\\M4[yes]うなずいてみるよ" page = { "list": [ {"code": 101, "indent": 0, "parameters": ["", 0, 0, 2, "セラス"]}, {"code": 401, "indent": 0, "parameters": [src]}, ] } page1, captured1 = _run_search_codes(page) cmd = _find_commands(page1, 401)[0] self.assertEqual(cmd.get("_original"), src) out = cmd["parameters"][0] self.assertEqual(out.count("\\F4[2]"), 1, out) self.assertEqual(out.count("\\AA[4]"), 1, out) self.assertEqual(out.count("\\M4[yes]"), 1, out) self.assertTrue(out.startswith("\\F4[2]\\AA[4]\\M4[yes]"), out) for payload in captured1: items = payload if isinstance(payload, list) else [payload] for item in items: if isinstance(item, str): self.assertNotIn("\\F4[2]", item, f"codes must be stripped before AI: {item!r}") # Simulate a previously-bugged double prefix still sitting in parameters. cmd["parameters"][0] = "\\F4[2]\\AA[4]\\M4[yes]" + out page2, _ = _run_search_codes(page1) cmd2 = _find_commands(page2, 401)[0] out2 = cmd2["parameters"][0] self.assertEqual(cmd2.get("_original"), src) self.assertEqual(out2.count("\\F4[2]"), 1, out2) self.assertEqual(out2.count("\\AA[4]"), 1, out2) self.assertEqual(out2.count("\\M4[yes]"), 1, out2) def test_speaker_color_line_full_original(self): """Standalone \\C[n]Name\\C[n] speaker 401 lines keep the full string in _original.""" page = { "list": [ {"code": 401, "indent": 0, "parameters": ["\\C[2]エルーシャ\\C[0]"]}, {"code": 401, "indent": 0, "parameters": ["「テストセリフ」"]}, ] } page, _ = _run_search_codes(page) speaker_cmd = page["list"][0] self.assertEqual(speaker_cmd.get("_original"), "\\C[2]エルーシャ\\C[0]") self.assertIn("\\C[2]", speaker_cmd["parameters"][0]) self.assertIn("Speaker_エルーシャ", speaker_cmd["parameters"][0]) # Re-run: _original unchanged, getSpeaker still receives Japanese name speakers_seen = [] def speaker(name): speakers_seen.append(name) return _mock_speaker(name) orig_t, orig_s = mvmz.translateAI, mvmz.getSpeaker orig_401 = mvmz.CODE401 mvmz.getSpeaker = speaker mvmz.CODE401 = True mvmz.translateAI = lambda text, history, batch=False: _mock_translate(text, history, batch) try: mvmz.searchCodes(page, None, [], "TestMap.json") finally: mvmz.getSpeaker = orig_s mvmz.translateAI = orig_t mvmz.CODE401 = orig_401 self.assertEqual(speaker_cmd["_original"], "\\C[2]エルーシャ\\C[0]") self.assertIn("エルーシャ", speakers_seen) def test_405_split_rerun_uses_anchor_original_only(self): """English 405 siblings after a split must not pollute re-run source.""" page = { "list": [ { "code": 405, "indent": 0, "parameters": ["EN_LINE_1"], "_original": "第一行\n第二行", }, {"code": 405, "indent": 0, "parameters": ["EN_LINE_2"]}, ] } _, captured = _run_search_codes(page) self.assertGreater(len(captured), 0) payloads = captured if isinstance(captured[0], str) else captured for payload in payloads: if not isinstance(payload, str): continue self.assertIn("第一行", payload) self.assertNotIn("EN_LINE_1", payload) self.assertNotIn("EN_LINE_2", payload) def test_408_choice_help_original(self): page = { "list": [ {"code": 108, "indent": 0, "parameters": ["選択肢ヘルプ"]}, {"code": 408, "indent": 0, "parameters": ["これは選択肢のヘルプです。"]}, ] } page, captured = _run_search_codes(page) cmd = _find_commands(page, 408)[0] self.assertEqual(cmd.get("_original"), "これは選択肢のヘルプです。") self.assertNotEqual(cmd["parameters"][0], cmd["_original"]) self.assertGreater(len(captured), 0) page2, captured2 = _run_search_codes(page) self.assertEqual(cmd["_original"], _find_commands(page2, 408)[0]["_original"]) for payload in captured2: items = payload if isinstance(payload, list) else [payload] for item in items: if not isinstance(item, str) or item == "EN_TRANSLATED": continue self.assertTrue(_has_japanese(item), f"408 re-run sent non-Japanese: {item!r}") class TestFixtureMapOriginal(unittest.TestCase): """Full fixture map covering every _original preservation code path.""" def test_fixture_all_cases_original(self): page, _ = _run_search_codes(_load_fixture_page()) marked = _case_commands(page["list"]) manifest = _load_fixture_manifest() for entry in manifest["cases"]: cid = entry["id"] cmd = _resolve_case_command(page["list"], entry, marked) expected = entry["expected_original"] with self.subTest(case=cid): self.assertIsNotNone(cmd, f"could not resolve fixture case {cid}") self.assertEqual(cmd.get("_original"), expected, entry.get("summary", cid)) if isinstance(expected, str): self.assertTrue(_has_japanese(expected)) if cmd.get("parameters"): display = cmd["parameters"][0] if isinstance(display, str): self.assertNotEqual(display, expected) def test_fixture_rerun_preserves_original(self): page1, _ = _run_search_codes(_load_fixture_page()) page2, captured2 = _run_search_codes(page1) manifest = _load_fixture_manifest() for entry in manifest["cases"]: cid = entry["id"] cmd1 = _resolve_case_command(page1["list"], entry) cmd2 = _resolve_case_command(page2["list"], entry) with self.subTest(case=cid): self.assertIsNotNone(cmd1) self.assertIsNotNone(cmd2) self.assertEqual(cmd1.get("_original"), cmd2.get("_original")) for payload in captured2: items = payload if isinstance(payload, list) else [payload] for item in items: if not isinstance(item, str) or item == "EN_TRANSLATED": continue if _has_japanese(item): continue if re.match(r"^\[.+?\]:\s*EN_TRANSLATED$", item): continue self.fail(f"Fixture re-run sent non-Japanese to translateAI: {item!r}") def test_preserve_original_codes_disabled(self): """PRESERVEORIGINAL=False must not write _original on map commands.""" page = { "list": [ {"code": 401, "indent": 0, "parameters": ["こんにちは"]}, ] } page, _ = _run_search_codes(page, preserve_original=False) self.assertNotIn("_original", page["list"][0]) if __name__ == "__main__": unittest.main(verbosity=2)