#!/usr/bin/env python3 """Unit tests for util/wolfdawn/inject.py orchestration.""" from __future__ import annotations import os import sys import tempfile import unittest from pathlib import Path from unittest import mock ROOT = Path(__file__).resolve().parents[1] os.chdir(ROOT) sys.path.insert(0, str(ROOT)) from util.wolfdawn import inject as wi # noqa: E402 class ListInjectableTests(unittest.TestCase): def test_lists_translated_files_with_manifest_entries(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) translated = root / "translated" translated.mkdir() (translated / "names.json").write_text("{}", encoding="utf-8") (translated / "Map001.mps.json").write_text("{}", encoding="utf-8") (translated / "orphan.json").write_text("{}", encoding="utf-8") entries = [ {"json": "names.json", "kind": "names", "base": "/data"}, {"json": "Map001.mps.json", "kind": "map", "base": "/data/Map001.mps"}, ] self.assertEqual( wi.list_injectable(translated, entries), ["Map001.mps.json", "names.json"], ) class InjectOrderTests(unittest.TestCase): def test_names_runs_before_strings(self): calls: list[str] = [] string_bases: list[str] = [] def fake_names(*_a, **_k): calls.append("names") return mock.Mock( ok=True, returncode=0, stdout="applied 2 name change(s) (0 drifted/unmatched)", stderr="", ) def fake_strings(edited_json, base, output, **_k): calls.append("strings") string_bases.append(str(base)) return mock.Mock( ok=True, returncode=0, stdout="applied 1 translation(s) (0 drifted)", stderr="", ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) translated = root / "translated" originals = root / "originals" data = root / "data" translated.mkdir() originals.mkdir() data.mkdir() (translated / "names.json").write_text("{}", encoding="utf-8") (translated / "Map001.mps.json").write_text( '{"kind":"map","scenes":[]}', encoding="utf-8" ) map_live = data / "Map001.mps" map_orig = originals / "Map001.mps" map_live.write_bytes(b"live") map_orig.write_bytes(b"orig") entries = [ {"json": "names.json", "kind": "names", "base": str(data)}, {"json": "Map001.mps.json", "kind": "map", "base": str(map_live)}, ] def would_apply_counts(): yield 1 # prepare: after restore yield 1 # _inject_names: preflight yield 0 # _inject_names: post-check counts = would_apply_counts() def next_would_apply(*_a, **_k): return next(counts, 0) with mock.patch.object(wi.wolf_originals, "names_inject_would_apply", side_effect=next_would_apply), \ mock.patch.object(wi.wolfdawn, "names_inject", side_effect=fake_names), \ mock.patch.object(wi.wolfdawn, "strings_inject", side_effect=fake_strings): report = wi.inject_selected( ["Map001.mps.json", wi.NAMES_JSON], manifest_entries=entries, data_dir=data, originals_dir=originals, translated_dir=translated, game_root=data.parent, ) self.assertEqual(calls, ["names", "strings"]) self.assertTrue(report.ok) self.assertEqual(len(report.succeeded), 2) # After names-inject, strings must use live Data/ as --base so # name-only fields are not rebuilt from pristine Japanese originals. self.assertEqual(string_bases, [str(map_live)]) def test_strings_alone_use_pristine_original_base(self): string_bases: list[str] = [] string_drift: list[bool] = [] def fake_strings(edited_json, base, output, **kwargs): string_bases.append(str(base)) string_drift.append(bool(kwargs.get("allow_code_drift"))) return mock.Mock( ok=True, returncode=0, stdout="applied 1 translation(s) (0 drifted)", stderr="", ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) translated = root / "translated" originals = root / "originals" data = root / "data" translated.mkdir() originals.mkdir() data.mkdir() (translated / "Map001.mps.json").write_text( '{"kind":"map","scenes":[]}', encoding="utf-8" ) map_live = data / "Map001.mps" map_orig = originals / "Map001.mps" map_live.write_bytes(b"live") map_orig.write_bytes(b"orig") entries = [ {"json": "Map001.mps.json", "kind": "map", "base": str(map_live)}, ] with mock.patch.object(wi.wolfdawn, "strings_inject", side_effect=fake_strings): report = wi.inject_selected( ["Map001.mps.json"], manifest_entries=entries, data_dir=data, originals_dir=originals, translated_dir=translated, game_root=data.parent, ) self.assertTrue(report.ok) self.assertEqual(string_bases, [str(map_orig)]) self.assertEqual(string_drift, [False]) def test_strings_auto_allow_code_drift_for_font_size(self): string_drift: list[bool] = [] def fake_strings(edited_json, base, output, **kwargs): string_drift.append(bool(kwargs.get("allow_code_drift"))) return mock.Mock( ok=True, returncode=0, stdout="applied 1 translation(s) (0 drifted)", stderr="", ) with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) translated = root / "translated" originals = root / "originals" data = root / "data" translated.mkdir() originals.mkdir() data.mkdir() doc = { "kind": "db", "groups": [ { "typeName": "噂", "lines": [ { "source": r"\c[21]\f[20]娼館\c[19]\f[18]", "text": r"\f[14]\c[21]\f[16]Brothel\c[19]\f[14]", } ], } ], } import json (translated / "DataBase.project.json").write_text( json.dumps(doc, ensure_ascii=False), encoding="utf-8" ) proj_live = data / "DataBase.project" proj_orig = originals / "DataBase.project" proj_live.write_bytes(b"live") proj_orig.write_bytes(b"orig") (data / "DataBase.dat").write_bytes(b"live-dat") (originals / "DataBase.dat").write_bytes(b"orig-dat") entries = [ { "json": "DataBase.project.json", "kind": "db", "base": str(proj_live), }, ] with mock.patch.object(wi.wolfdawn, "strings_inject", side_effect=fake_strings): report = wi.inject_selected( ["DataBase.project.json"], manifest_entries=entries, data_dir=data, originals_dir=originals, translated_dir=translated, game_root=data.parent, allow_code_drift=False, ) self.assertTrue(report.ok) self.assertEqual(string_drift, [True]) def test_names_result_mentions_safety_skips(self): res = mock.Mock( ok=True, returncode=0, stdout=( "would apply 10 name change(s) (0 drifted/unmatched)\n" "WARNING: 46 line(s) left UNTRANSLATED by a safety guard - " "46 control-code mismatch, 0 not encodable\n" ), stderr="", ) # parse_names_inject_counts needs "applied N name change" res.stdout = ( "applied 10 name change(s) (0 drifted/unmatched)\n" "WARNING: 46 line(s) left UNTRANSLATED by a safety guard - " "46 control-code mismatch, 0 not encodable\n" ) result = wi._interpret_names_result("names.json", res) self.assertTrue(result.success) self.assertIn("46 skipped by safety guard", result.summary) class ReportDialogTests(unittest.TestCase): def test_success_has_no_dialog(self): report = wi.InjectReport( files=[ wi.FileInjectResult("a.json", True, "applied 3 line(s)"), wi.FileInjectResult("b.json", True, "no changes needed"), ], ) self.assertIsNone(wi.format_report_dialog(report)) self.assertEqual(wi.format_report_status(report), "Inject complete: 2 file(s).") def test_failure_dialog_lists_only_problems(self): report = wi.InjectReport( files=[ wi.FileInjectResult("a.json", True, "applied 3 line(s)"), wi.FileInjectResult("b.json", False, "0 applied, 2 drifted", detail="stderr"), ], sync_failures=[("c.json", "permission denied")], ) dialog = wi.format_report_dialog(report) self.assertIsNotNone(dialog) title, body = dialog self.assertIn("errors", title) self.assertIn("✗ b.json", body) self.assertIn("✗ sync c.json", body) self.assertNotIn("✓ a.json", body) self.assertIn("1 file(s) succeeded", body) self.assertEqual( wi.format_report_status(report), "Inject: 1 ok, 2 failed (see dialog).", ) if __name__ == "__main__": unittest.main()