Compare commits

...

2 commits

Author SHA1 Message Date
eca44a15bd Trim tests 2026-07-09 07:29:20 -05:00
15eac9b1bf Some small housekeeping 2026-07-09 07:25:36 -05:00
7 changed files with 133 additions and 815 deletions

View file

@ -18,15 +18,16 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
and refresh the git-tracked wolf_json/ with the translated
JSON; quick-inject picks just a few translated files for
fast in-game / git iteration, or inject everything at once.
Step 7 Fix wrap - search translated JSON by in-game text; wrap DB sheet rows;
wolf names-wrap, relayout, and desc-relayout
Step 7 Fix wrap - search translated JSON by in-game text; Relayout
(names-wrap / cell geometry) or Manual (width + font);
inject the edited file
Step 8 Package - run from a loose Data/ folder, or repack Data.wolf
Step 9 Saves - fix baked strings in existing .sav files (optional)
names.json is staged into files/ but is NOT translated in the bulk phases - WolfDawn
tags each name safe / refs / verify and Phase 0 translates only safe entries
(per-name, not per-category), harvests them into vocab.txt, and Step 6 injects the
result. All text wrapping (names-wrap, relayout, manual sheet wrap) lives in Step 7.
result. All text wrapping lives in Step 7.
The extracted JSON is staged in ``<game_root>/wolf_json/`` (a manifest maps each
file back to its base binary), then imported into ``files/`` for the shared
@ -90,7 +91,6 @@ from util.wolfdawn import codes as wolf_codes
from util.wolfdawn import db_classify as wolf_db
from util.wolfdawn import wrap_search as wolf_ws
import util.dazedwrap as dazedwrap
from util.paths import PROJECT_ROOT
from util.project_scanner import (
detect_wolf_layout,
@ -3503,180 +3503,6 @@ class WolfWorkflowTab(QWidget):
self._run_task(task)
def _relayout_desc_enabled(self) -> bool:
return str(self._setting("relayout_desc", "true")).lower() != "false"
def _relayout_settings(self) -> dict:
def _int_setting(key: str, default: int) -> int:
try:
return int(self._setting(key, default) or default)
except (TypeError, ValueError):
return default
def _width(key: str) -> int | str:
if str(self._setting(f"{key}_auto", "true")).lower() != "false":
return "auto"
return _int_setting(key, 55 if key == "relayout_width" else 75)
return {
"width": _width("relayout_width"),
"max_rows": _int_setting("relayout_max_rows", 0),
"desc_width": _width("relayout_desc_width"),
"desc_max_lines": _int_setting("relayout_desc_max_lines", 0),
}
def _find_db_projects(self, data_dir: Path) -> list[Path]:
"""DataBase / CDataBase / SysDatabase.project under BasicData or flat Data/."""
found: list[Path] = []
for base in (data_dir / "BasicData", data_dir):
if not base.is_dir():
continue
for name in ("DataBase.project", "CDataBase.project", "SysDatabase.project"):
p = base / name
if p.is_file() and p not in found:
found.append(p)
return found
def _sync_relayout_tree(self, src_root: Path, dest_root: Path, log=None) -> int:
"""Copy every file under *src_root* onto *dest_root*, preserving relative paths."""
copied = 0
for src in src_root.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(src_root)
dest = dest_root / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
copied += 1
return copied
def _run_relayout(self, *, manual: bool = False, quiet: bool = False):
"""Reflow Message text (and optionally DB descriptions) on live Data/."""
if not self._require_manifest():
return
manifest = self._read_manifest()
data_dir = Path(manifest["data_dir"])
if not data_dir.is_dir():
QMessageBox.warning(self, "Relayout", f"Data/ folder not found:\n{data_dir}")
return
opts = self._relayout_settings()
do_desc = self._relayout_desc_enabled()
def task(log, progress=None):
from util import wolfdawn
if not quiet:
log(f"Relayouting {data_dir}")
failures: list[str] = []
with tempfile.TemporaryDirectory(prefix="wolfdawn-relayout-") as tmp:
out_dir = Path(tmp) / "out"
out_dir.mkdir(parents=True, exist_ok=True)
res = wolfdawn.relayout(
data_dir,
out_dir=out_dir,
width=opts["width"],
max_rows=opts["max_rows"],
log_fn=None,
)
if not res.ok and res.returncode not in (0, 2):
failures.append(f"message reflow exited {res.returncode}")
else:
self._sync_relayout_tree(out_dir, data_dir)
if do_desc and not failures:
projects = self._find_db_projects(data_dir)
for proj in projects:
with tempfile.TemporaryDirectory(prefix="wolfdawn-desc-") as tmp:
tmp_path = Path(tmp)
out_proj = tmp_path / proj.name
dres = wolfdawn.desc_relayout(
proj,
out_proj,
width=opts["desc_width"],
max_lines=opts["desc_max_lines"],
log_fn=None,
)
if not dres.ok and dres.returncode not in (0, 2):
failures.append(
f"desc-relayout exited {dres.returncode} for {proj.name}"
)
continue
for produced in tmp_path.iterdir():
if not produced.is_file():
continue
dest = proj.parent / produced.name
shutil.copy2(produced, dest)
if quiet:
if failures:
log("Relayout: failed — " + "; ".join(failures))
else:
log("Relayout: applied")
return not failures, ""
if failures:
return False, "Relayout failed — " + "; ".join(failures)
return True, "Relayout complete." + (
" Continue to Step 8 to package." if not manual else ""
)
self._run_task(task)
def _run_names_wrap(self, *, manual: bool = False, dry_run: bool = False, note: str | None = None):
"""Run ``wolf names-wrap`` on translated/names.json."""
names_path = self._tool_root() / "translated" / NAMES_JSON
if not names_path.is_file():
names_path = self._work_dir() / NAMES_JSON
if not names_path.is_file():
QMessageBox.warning(
self,
"names-wrap",
"No names.json found in translated/ or wolf_json/. Run Step 0 first.",
)
return
hit = (
self._current_wrap_hit
if self._current_wrap_hit is not None and self._current_wrap_hit.kind == "names"
else None
)
width = self._wrap_width_spin.value() if hasattr(self, "_wrap_width_spin") else 0
max_lines = self._wrap_max_lines_value()
wrap_width = width if width > 0 else None
wrap_lines = max_lines or None
def task(log, progress=None):
if note and not dry_run and (wrap_width or wrap_lines):
roles_path = wolf_names.roles_json_path_for_names(names_path)
wolf_names.upsert_name_wrap_role(
roles_path,
note,
width=wrap_width,
max_lines=wrap_lines,
)
ok, summary = wolf_names.apply_names_wrap(
names_path,
note=note,
width=wrap_width,
lines=wrap_lines,
dry_run=dry_run,
log_fn=log,
)
if not ok:
return False, f"names-wrap failed — {summary}"
if manual and not dry_run:
self._sync_translated_json_to_wolf_json(NAMES_JSON, self._work_dir())
return True, summary + (
"" if dry_run else " Re-inject names.json from Step 6 if needed."
)
def _after(ok: bool, _msg: str):
if ok and manual and not dry_run and hit is not None and hit.kind == "names":
self._reload_wrap_hit_editor(hit, self._active_wrap_width())
self._run_task(task, on_done=_after)
# ── Step 5: Package ────────────────────────────────────────────────────────
def _build_step5_package(self, layout: QVBoxLayout):

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Tests for WOLF inline control-code repair."""
"""Tests for WOLF inline control-code repair and font scaling."""
from __future__ import annotations
@ -59,95 +59,33 @@ class WolfCodesRepairTests(unittest.TestCase):
"Fortune-teller\nHa!\\^",
)
def test_apply_font_size_replaces_existing_codes(self):
text = r"Go to the \c[21]\f[20]tavern\c[19]\f[18]!"
new_text, count = wolf_codes.apply_font_size(text, 16)
self.assertEqual(count, 3) # 2 replaced + leading body
self.assertTrue(new_text.startswith(r"\f[16]"))
self.assertIn(r"\f[16]", new_text)
class WolfFontScaleTests(unittest.TestCase):
def test_scale_keeps_emphasis_ratio_and_leads_with_body(self):
# Cafe-style mid-line emphasis: body must lead so Wolf sets line height.
text = (
'"That \\c[21]\\f[20]cafe\\c[19]\\f[18] over there has been\n'
"packed lately.\""
)
new_text, _ = wolf_codes.scale_font_sizes(text, 13)
self.assertTrue(new_text.startswith(r"\f[13]"))
# 20 * 13/18 ≈ 14.4 → 14; body 13
self.assertIn(r"\c[21]\f[14]cafe\c[19]\f[13]", new_text)
self.assertNotIn(r"\f[20]", new_text)
self.assertNotIn(r"\f[18]", new_text)
def test_apply_font_size_prepends_when_missing(self):
text = "Plain text without font codes"
new_text, count = wolf_codes.apply_font_size(text, 18)
self.assertEqual(count, 1)
self.assertTrue(new_text.startswith(r"\f[18]"))
def test_infer_base_font_prefers_body_over_emphasis(self):
text = r"So they even take worries\nlike this in the\n\c[21]\f[20]confessional\c[19]\f[18]..."
self.assertEqual(wolf_codes.infer_base_font_size(text), 18)
def test_scale_font_sizes_keeps_emphasis_ratio(self):
text = (
r"So they even take worries\nlike this in the\n"
r"\c[21]\f[20]confessional\c[19]\f[18]..."
)
new_text, count = wolf_codes.scale_font_sizes(text, 14)
self.assertEqual(count, 3) # 2 scaled + leading body insert
# 20 * 14/18 ≈ 15.56 → 16; 18 * 14/18 = 14
self.assertTrue(new_text.startswith(r"\f[14]"))
self.assertIn(r"\f[16]", new_text)
self.assertIn(r"\f[14]", new_text)
self.assertNotIn(r"\f[20]", new_text)
self.assertNotIn(r"\f[18]", new_text)
def test_scale_font_sizes_matches_names_wrap_style_shrink(self):
source_style = r"\c[21]\f[20]Brothel\c[19]\f[18] doesn't get much use"
new_text, _ = wolf_codes.scale_font_sizes(source_style, 17)
# 20*17/18 ≈ 18.89 → 19; body 17
self.assertTrue(new_text.startswith(r"\f[17]"))
self.assertIn(r"\f[19]", new_text)
self.assertIn(r"\f[17]", new_text)
def test_scale_font_sizes_leads_with_body_for_midline_emphasis(self):
# Cafe-style: body only appears after emphasis; Wolf needs \\f at start.
text = (
'"That \\c[21]\\f[14]cafe\\c[19]\\f[13] over there has been\n'
'packed lately." "Apparently\n'
"they're hiring waitresses.\""
)
new_text, count = wolf_codes.scale_font_sizes(text, 13)
self.assertGreaterEqual(count, 2)
self.assertTrue(new_text.startswith(r"\f[13]"))
self.assertIn(r"\c[21]\f[14]cafe\c[19]\f[13]", new_text)
def test_ensure_leading_font_size_idempotent(self):
text = r'\f[13]"That \c[21]\f[14]cafe\c[19]\f[13] over there"'
self.assertEqual(wolf_codes.ensure_leading_font_size(text, 13), text)
def test_apply_font_size_also_leads(self):
text = r'"That \c[21]\f[20]cafe\c[19]\f[18] over there"'
new_text, _ = wolf_codes.apply_font_size(text, 13)
self.assertTrue(new_text.startswith(r"\f[13]"))
self.assertNotIn(r"\f[20]", new_text)
def test_font_size_codes_differ_detects_names_wrap_shrink(self):
source = (
r"「おい!例の\c[21]\f[20]教会の特別奉仕活動\c[19]\f[18]があるってよ!」"
)
text = (
r'\f[14]"Hey! Apparently there\'s one of those '
r'\c[21]\f[16]Special Service Activities at the church\c[19]\f[14]!"'
)
self.assertTrue(wolf_codes.font_size_codes_differ(source, text))
def test_font_size_codes_differ_false_when_color_missing(self):
def test_font_size_codes_differ_only_for_size_drift(self):
source = r"\c[21]\f[20]娼館\c[19]\f[18]"
text = r"\f[14]Brothel" # dropped colour codes
self.assertFalse(wolf_codes.font_size_codes_differ(source, text))
def test_names_doc_has_font_size_drift(self):
doc = {
"kind": "names",
"names": [
{
"source": r"\c[21]\f[20]娼館\c[19]\f[18]",
"text": r"\f[14]\c[21]\f[16]Brothel\c[19]\f[14]",
}
],
}
self.assertTrue(wolf_codes.names_doc_has_font_size_drift(doc))
shrunk = r"\f[14]\c[21]\f[16]Brothel\c[19]\f[14]"
dropped_color = r"\f[14]Brothel"
self.assertTrue(wolf_codes.font_size_codes_differ(source, shrunk))
self.assertFalse(wolf_codes.font_size_codes_differ(source, dropped_color))
self.assertTrue(
wolf_codes.names_doc_has_font_size_drift(
{"kind": "names", "names": [{"source": source, "text": shrunk}]}
)
)
if __name__ == "__main__":

View file

@ -137,34 +137,14 @@ class TestNameWrapRoles(unittest.TestCase):
def tearDown(self):
self._tmp.cleanup()
def test_roles_path_beside_names(self):
self.assertEqual(
wn.roles_json_path_for_names(self.names),
self.dir / "wolfdawn-roles.json",
)
def test_upsert_and_load_name_wrap_role(self):
def test_upsert_load_and_update_name_wrap_role(self):
roles_path = wn.roles_json_path_for_names(self.names)
wn.upsert_name_wrap_role(roles_path, "説明", width=48, max_lines=3)
roles = wn.load_name_wrap_roles(roles_path)
role = wn.get_name_wrap_role(roles, "説明")
self.assertIsNotNone(role)
assert role is not None
self.assertEqual(role["width"], 48)
self.assertEqual(role["maxLines"], 3)
def test_upsert_updates_existing_note(self):
roles_path = wn.roles_json_path_for_names(self.names)
wn.upsert_name_wrap_role(roles_path, "説明", width=40)
self.assertEqual(roles_path, self.dir / "wolfdawn-roles.json")
wn.upsert_name_wrap_role(roles_path, "説明", width=40, font=14)
wn.upsert_name_wrap_role(roles_path, "説明", width=52, max_lines=4)
role = wn.get_name_wrap_role(wn.load_name_wrap_roles(roles_path), "説明")
self.assertEqual(role["width"], 52)
self.assertEqual(role["maxLines"], 4)
def test_upsert_persists_font(self):
roles_path = wn.roles_json_path_for_names(self.names)
wn.upsert_name_wrap_role(roles_path, "説明", width=40, font=14)
role = wn.get_name_wrap_role(wn.load_name_wrap_roles(roles_path), "説明")
self.assertEqual(role["font"], 14)

View file

@ -53,21 +53,6 @@ RUMOR_FIXTURE = {
}
class TestWolfVisibleLength(unittest.TestCase):
def test_font_codes_do_not_count_toward_width(self):
plain = "Hello world"
coded = r"Hello \f[2]world"
self.assertEqual(
dazedwrap.max_line_visible_length(plain),
dazedwrap.max_line_visible_length(coded),
)
def test_gatekeeper_line_is_overflow_at_bulletin_width(self):
length = dazedwrap.max_line_visible_length(GATEKEEPER_TEXT)
self.assertGreater(length, 40)
self.assertGreater(length, 34)
class TestWrapSearch(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
@ -81,172 +66,29 @@ class TestWrapSearch(unittest.TestCase):
def tearDown(self):
self.tmp.cleanup()
def test_search_finds_gatekeeper_in_rumor_sheet(self):
def test_search_and_wrap_db_sheet(self):
hits = ws.search_translated_text("gatekeeper just randomly", self.translated)
self.assertEqual(len(hits), 1)
hit = hits[0]
self.assertEqual(hit.kind, "db")
self.assertEqual(hit.sheet_name, RUMOR_SHEET)
self.assertEqual(hit.row, GATEKEEPER_ROW)
self.assertEqual(hit.field_name, GATEKEEPER_FIELD)
self.assertEqual(hit.json_file, "DataBase.project.json")
self.assertIn("gatekeeper", hit.text.lower())
def test_search_finds_horned_rabbits_same_row(self):
hits = ws.search_translated_text("horned rabbit", self.translated)
self.assertEqual(len(hits), 1)
self.assertEqual(hits[0].row, GATEKEEPER_ROW)
def test_wrap_overflow_in_sheet_changes_long_lines(self):
path = self.translated / "DataBase.project.json"
doc = json.loads(path.read_text(encoding="utf-8"))
changed = ws.wrap_overflow_in_sheet(path, doc, RUMOR_SHEET, width=34)
self.assertEqual(changed, 1)
doc2 = json.loads(path.read_text(encoding="utf-8"))
line = ws.locate_line(
doc2,
{
"kind": "db",
"sheet_name": RUMOR_SHEET,
"row": GATEKEEPER_ROW,
"field_name": GATEKEEPER_FIELD,
},
)
self.assertIsNotNone(line)
line = ws.locate_line(doc, hit.hit_id)
assert line is not None
self.assertIn("\n", line["text"])
self.assertLessEqual(dazedwrap.max_line_visible_length(line["text"]), 34)
def test_wrap_profile_remembers_sheet_width(self):
ws.set_sheet_width(self.work, RUMOR_SHEET, 34, json_file="DataBase.project.json")
profile = ws.load_wrap_profile(self.work)
self.assertEqual(ws.get_sheet_width(profile, RUMOR_SHEET), 34)
entry = profile["sheets"][RUMOR_SHEET]
self.assertEqual(entry["json_file"], "DataBase.project.json")
def test_sheet_overflow_summary_counts_gatekeeper_row(self):
summaries = ws.sheet_overflow_summaries(self.translated, width=34)
rumor = [s for s in summaries if s.sheet_name == RUMOR_SHEET]
self.assertEqual(len(rumor), 1)
self.assertEqual(rumor[0].overflow_count, 1)
self.assertEqual(rumor[0].line_count, 2)
class TestNamesSearch(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.translated = Path(self.tmp.name) / "translated"
self.translated.mkdir()
fixture = {
"kind": "names",
"count": 1,
"names": [
{
"source": "ブロthel scout line",
"text": (
"Maybe I'll go scout out some newcomers at the brothel.\n"
"\u3000Hope there's a pure-looking girl with big boobs..."
),
"note": "Profile · プロフィール",
"safety": "safe",
}
],
}
(self.translated / "names.json").write_text(
json.dumps(fixture, ensure_ascii=False, indent=4),
encoding="utf-8",
self.assertEqual(
ws.get_sheet_width(ws.load_wrap_profile(self.work), RUMOR_SHEET), 34
)
def tearDown(self):
self.tmp.cleanup()
def test_search_finds_names_json_profile_text(self):
hits = ws.search_translated_text("there's a pure", self.translated)
self.assertEqual(len(hits), 1)
hit = hits[0]
self.assertEqual(hit.kind, "names")
self.assertEqual(hit.json_file, "names.json")
self.assertIn("pure-looking", hit.text)
def test_load_hit_from_names_index(self):
hits = ws.search_translated_text("pure-looking", self.translated)
self.assertEqual(len(hits), 1)
path, doc, line = ws.load_hit_from_id(self.translated, hits[0].hit_id)
self.assertIsNotNone(path)
assert path is not None and line is not None
self.assertEqual(path.name, "names.json")
self.assertIn("pure-looking", line["text"])
class TestWrapPreview(unittest.TestCase):
SAMPLE = (
'"Apparently there\'s a super hot female adventurer who just '
'came to town!? Maybe I can meet her if I head to the '
r'\c[21]\f[20]tavern\c[19]\f[18]!"'
)
def test_preview_detects_wrap_needed(self):
info = ws.wrap_preview_info(self.SAMPLE, 40)
self.assertTrue(info["needs_wrap"])
self.assertGreater(info["longest"], 40)
self.assertGreater(info["output_line_count"], 1)
def test_format_preview_shows_line_counts(self):
preview = ws.format_wrap_preview(self.SAMPLE, 40)
self.assertIn("(40)", preview)
self.assertIn("Apparently", preview)
def test_split_line_at_visible_width(self):
line = "Apparently there's a super hot female adventurer who just"
fit_end, line_len = ws.split_line_at_visible_width(line, 40)
self.assertEqual(line_len, len(line))
self.assertGreater(fit_end, 0)
self.assertLess(fit_end, line_len)
self.assertLessEqual(
dazedwrap.max_line_visible_length(line[:fit_end]),
40,
)
def test_codes_do_not_inflate_visible_counts(self):
text = r'See the \c[21]\f[20]tavern\c[19]\f[18] on Main Street'
info = ws.wrap_preview_info(text, 30)
self.assertLess(info["longest"], len(text))
self.assertLessEqual(info["longest"], 30 + 10)
def test_render_wolf_text_html_hides_codes_and_styles(self):
text = r'That \c[21]\f[20]cafe\c[19]\f[18] over there'
html = ws.render_wolf_text_html(text, default_font_px=18)
self.assertNotIn(r"\c[", html)
self.assertNotIn(r"\f[", html)
self.assertIn("cafe", html)
self.assertIn("font-size:20px", html)
self.assertIn("font-size:18px", html)
# Emphasis colour for \\c[21]
self.assertIn(ws.wolf_preview_color(21), html)
def test_format_wrap_preview_html_includes_gutters(self):
text = (
r'\f[18]"That \c[21]\f[20]cafe\c[19]\f[18] over there has been'
'\npacked lately."'
)
html = ws.format_wrap_preview_html(text, 40)
self.assertIn("(37)", html)
self.assertIn("cafe", html)
self.assertNotIn(r"\f[18]", html)
self.assertIn("font-size:20px", html)
self.assertIn(ws.wolf_preview_color(21), html)
def test_format_wrap_preview_html_simulates_body_font(self):
text = r'That \c[21]\f[20]cafe\c[19]\f[18] over there'
html = ws.format_wrap_preview_html(text, 40, body_font=13)
# 20*13/18 ≈ 14; body 13
self.assertIn("font-size:14px", html)
self.assertIn("font-size:13px", html)
self.assertNotIn("font-size:20px", html)
self.assertNotIn("font-size:18px", html)
class TestNamesCategoryWrap(unittest.TestCase):
class TestNamesSearchAndWrap(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.translated = Path(self.tmp.name) / "translated"
@ -254,11 +96,10 @@ class TestNamesCategoryWrap(unittest.TestCase):
self.category = "├■街の噂MOB"
fixture = {
"kind": "names",
"count": 2,
"names": [
{
"source": "短い",
"text": "Short line.",
"source": "「メイドがどんどん辞めていく」",
"text": '"Yeah, maids keep quitting one after another."',
"note": self.category,
},
{
@ -277,107 +118,44 @@ class TestNamesCategoryWrap(unittest.TestCase):
def tearDown(self):
self.tmp.cleanup()
def test_names_categories_in_sheet_summaries(self):
summaries = ws.sheet_overflow_summaries(self.translated, width=34)
names_rows = [s for s in summaries if s.kind == "names" and s.sheet_name == self.category]
self.assertEqual(len(names_rows), 1)
self.assertEqual(names_rows[0].line_count, 2)
self.assertEqual(names_rows[0].overflow_count, 1)
def test_search_summary_shows_translated_text(self):
hits = ws.search_translated_text("maids", self.translated)
self.assertEqual(len(hits), 1)
summary = hits[0].summary(40)
self.assertTrue(summary.split("\n", 1)[0].startswith('"Yeah, maids'))
self.assertNotIn("メイド", summary)
path, _doc, line = ws.load_hit_from_id(self.translated, hits[0].hit_id)
assert path is not None and line is not None
self.assertEqual(path.name, "names.json")
self.assertIn("maids", line["text"])
def test_wrap_all_in_names_category(self):
def test_wrap_overflow_in_names_category(self):
doc = json.loads(self.path.read_text(encoding="utf-8"))
changed = ws.wrap_overflow_in_sheet(
self.path, doc, self.category, 34, kind="names",
)
self.assertEqual(changed, 1)
doc2 = json.loads(self.path.read_text(encoding="utf-8"))
long_entry = doc2["names"][1]["text"]
self.assertIn("\n", long_entry)
def test_names_search_summary_shows_translated_text(self):
hits = ws.search_translated_text("maids", self.translated)
# Seed a maid line so EN query matches EN text, not JP source.
doc = json.loads(self.path.read_text(encoding="utf-8"))
doc["names"].append(
{
"source": "「メイドがどんどん辞めていく」",
"text": '"Yeah, maids keep quitting one after another."',
"note": self.category,
}
)
self.path.write_text(json.dumps(doc, ensure_ascii=False, indent=4), encoding="utf-8")
hits = ws.search_translated_text("maids", self.translated)
self.assertEqual(len(hits), 1)
summary = hits[0].summary(40)
self.assertIn("maids", summary)
self.assertNotIn("メイド", summary)
self.assertIn("entry #2", summary)
# Translated preview is the first line of the summary.
self.assertIn("\n", summary)
self.assertTrue(summary.split("\n", 1)[0].strip().startswith('"Yeah'))
self.assertGreaterEqual(changed, 1)
long_text = json.loads(self.path.read_text(encoding="utf-8"))["names"][1]["text"]
self.assertIn("\n", long_text)
self.assertLessEqual(dazedwrap.max_line_visible_length(long_text), 34)
class TestEventScopeWrap(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.translated = Path(self.tmp.name) / "translated"
self.translated.mkdir()
self.path = self.translated / "Map001.mps.json"
self.path.write_text(
json.dumps(
{
"kind": "map",
"file": "Map001.mps",
"scenes": [
{
"event": 1,
"lines": [
{
"text": (
"This is a very long line of dialogue that "
"should wrap at thirty four visible chars."
),
},
{"text": "Short line."},
],
}
],
},
ensure_ascii=False,
indent=4,
),
encoding="utf-8",
)
self.hit = ws.WrapHit(
json_file="Map001.mps.json",
kind="map",
sheet_name="Map001.mps",
row=1,
field_name="scene 0 line 0",
text="very long line",
max_line_len=80,
scene_index=0,
line_index=0,
map_file="Map001.mps",
)
def tearDown(self):
self.tmp.cleanup()
def test_scope_stats_for_map_file(self):
doc = json.loads(self.path.read_text(encoding="utf-8"))
stats = ws.scope_stats(doc, self.hit, 34)
self.assertEqual(stats.total, 2)
self.assertEqual(stats.overflow, 1)
def test_wrap_all_in_map_file(self):
doc = json.loads(self.path.read_text(encoding="utf-8"))
changed = ws.wrap_overflow_in_scope(self.path, doc, self.hit, 34)
self.assertEqual(changed, 1)
class TestWrapPreviewHtml(unittest.TestCase):
def test_html_preview_hides_codes_and_simulates_body_font(self):
text = r'That \c[21]\f[20]cafe\c[19]\f[18] over there'
html = ws.format_wrap_preview_html(text, 40, body_font=13)
self.assertNotIn(r"\c[", html)
self.assertNotIn(r"\f[", html)
self.assertIn("cafe", html)
self.assertIn("font-size:14px", html) # 20*13/18
self.assertIn("font-size:13px", html)
self.assertIn(ws.wolf_preview_color(21), html)
info = ws.wrap_preview_info(text, 40)
self.assertLess(info["longest"], len(text))
class TestManualFontAndWrap(unittest.TestCase):
def test_scales_emphasis_then_wraps(self):
def test_manual_scales_emphasis_wraps_and_skips_short_plain(self):
text = (
r"So they even take worries like this in the "
r"\c[21]\f[20]confessional\c[19]\f[18] booth every day."
@ -388,18 +166,15 @@ class TestManualFontAndWrap(unittest.TestCase):
self.assertIn(r"\f[14]", new_text)
self.assertLessEqual(dazedwrap.max_line_visible_length(new_text), 40)
def test_group_manual_skips_short_plain_lines(self):
text = "Short."
new_text, changed = ws.apply_manual_font_and_wrap(
text, 40, font=14, only_overflow_or_fonts=True
short, skipped = ws.apply_manual_font_and_wrap(
"Short.", 40, font=14, only_overflow_or_fonts=True
)
self.assertFalse(changed)
self.assertEqual(new_text, text)
self.assertFalse(skipped)
self.assertEqual(short, "Short.")
def test_wrap_overflow_manual_in_names_category(self):
tmp = tempfile.TemporaryDirectory()
try:
path = Path(tmp.name) / "names.json"
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "names.json"
doc = {
"kind": "names",
"names": [
@ -428,12 +203,48 @@ class TestManualFontAndWrap(unittest.TestCase):
changed = ws.wrap_overflow_manual_in_scope(path, doc, hit, 36, font=14)
self.assertEqual(changed, 1)
saved = json.loads(path.read_text(encoding="utf-8"))
out = saved["names"][0]["text"]
self.assertIn(r"\f[14]", out)
self.assertIn(r"\f[14]", saved["names"][0]["text"])
self.assertEqual(saved["names"][1]["text"], "OK")
self.assertEqual(saved["names"][2]["text"], "x" * 80)
finally:
tmp.cleanup()
class TestEventScopeWrap(unittest.TestCase):
def test_wrap_all_in_map_file(self):
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "Map001.mps.json"
doc = {
"kind": "map",
"file": "Map001.mps",
"scenes": [
{
"event": 1,
"lines": [
{
"text": (
"This is a very long line of dialogue that "
"should wrap at thirty four visible chars."
),
},
{"text": "Short line."},
],
}
],
}
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
hit = ws.WrapHit(
json_file="Map001.mps.json",
kind="map",
sheet_name="Map001.mps",
row=1,
field_name="scene 0 line 0",
text="very long line",
max_line_len=80,
scene_index=0,
line_index=0,
map_file="Map001.mps",
)
self.assertEqual(ws.scope_stats(doc, hit, 34).overflow, 1)
self.assertEqual(ws.wrap_overflow_in_scope(path, doc, hit, 34), 1)
if __name__ == "__main__":

View file

@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Unit tests for WolfDawn relayout / desc_relayout CLI wrappers."""
"""Unit tests for WolfDawn relayout / names-wrap CLI wrappers."""
from __future__ import annotations
@ -17,7 +17,7 @@ from util import wolfdawn # noqa: E402
class TestRelayoutWrappers(unittest.TestCase):
def test_relayout_dry_run_args(self):
def test_relayout_auto_metrics(self):
captured = {}
def fake_run(args, log_fn=None):
@ -25,40 +25,18 @@ class TestRelayoutWrappers(unittest.TestCase):
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.relayout("/game/Data", width=55, max_rows=0)
self.assertEqual(captured["args"][:2], ["relayout", "/game/Data"])
self.assertIn("--width", captured["args"])
self.assertIn("55", captured["args"])
self.assertNotIn("-o", captured["args"])
self.assertIn("--max-rows", captured["args"])
self.assertIn("auto", captured["args"])
def test_relayout_write_args(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.relayout(
"/game/Data",
out_dir="/tmp/out",
width=60,
max_rows=4,
no_resolve=True,
)
wolfdawn.relayout("/game/Data", width="auto", max_rows=0, out_dir="/tmp/out")
args = captured["args"]
self.assertEqual(args[0], "relayout")
self.assertEqual(args[:2], ["relayout", "/game/Data"])
self.assertIn("-o", args)
self.assertIn("/tmp/out", args)
self.assertIn("--width", args)
self.assertIn("60", args)
self.assertIn("auto", args)
self.assertIn("--max-rows", args)
self.assertIn("4", args)
self.assertIn("--no-resolve", args)
# max_rows=0 becomes auto
self.assertEqual(args[args.index("--max-rows") + 1], "auto")
def test_desc_relayout_args(self):
def test_desc_relayout_auto_max_lines(self):
captured = {}
def fake_run(args, log_fn=None):
@ -75,88 +53,32 @@ class TestRelayoutWrappers(unittest.TestCase):
)
args = captured["args"]
self.assertEqual(args[0], "desc-relayout")
self.assertIn("-o", args)
self.assertIn("/tmp/DataBase.project", args)
self.assertIn("--width", args)
self.assertIn("75", args)
self.assertIn("--max-lines", args)
self.assertIn("auto", args)
self.assertEqual(args[args.index("--max-lines") + 1], "auto")
self.assertIn("--keep-breaks", args)
def test_relayout_auto_width(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.relayout("/game/Data", width="auto", max_rows="auto")
args = captured["args"]
self.assertIn("--width", args)
self.assertIn("auto", args)
self.assertIn("--max-rows", args)
def test_names_wrap_args(self):
def test_names_wrap_flags_and_parsers(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(
0,
"names-wrap: 3 entry(ies) re-wrapped",
' shrunk to \\f[14] to fit its slot: "Hey! ..."\n'
' shrunk to \\f[12] to fit its slot: "That sister..."\n'
"names-wrap: 7 entry(ies) re-wrapped",
"",
[str(a) for a in args],
)
with patch.object(wolfdawn, "_run", side_effect=fake_run):
res = wolfdawn.names_wrap("/tmp/names.json")
self.assertEqual(captured["args"], ["names-wrap", "/tmp/names.json"])
self.assertEqual(wolfdawn.parse_names_wrap_counts(res.stdout), 3)
def test_names_wrap_dry_run(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(
0,
"names-wrap: 2 entry(ies) WOULD be re-wrapped",
"",
[str(a) for a in args],
)
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.names_wrap("/tmp/names.json", dry_run=True)
self.assertEqual(captured["args"], ["names-wrap", "/tmp/names.json", "--dry-run"])
def test_names_wrap_note_and_width(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.names_wrap(
res = wolfdawn.names_wrap(
"/tmp/names.json",
note="├■街の噂MOB",
width=66,
note="説明",
width=50,
lines=3,
dry_run=True,
)
self.assertEqual(
captured["args"],
["names-wrap", "/tmp/names.json", "--width", "66", "--note", "├■街の噂MOB"],
)
def test_names_wrap_lines(self):
captured = {}
def fake_run(args, log_fn=None):
captured["args"] = list(args)
return wolfdawn.WolfResult(0, "", "", [str(a) for a in args])
with patch.object(wolfdawn, "_run", side_effect=fake_run):
wolfdawn.names_wrap("/tmp/names.json", width=50, lines=3, note="説明")
self.assertEqual(
captured["args"],
[
@ -168,16 +90,11 @@ class TestRelayoutWrappers(unittest.TestCase):
"3",
"--note",
"説明",
"--dry-run",
],
)
def test_parse_names_wrap_shrink_count(self):
blob = (
' shrunk to \\f[14] to fit its slot: "Hey! ..."\n'
' shrunk to \\f[12] to fit its slot: "That sister..."\n'
"names-wrap: 7 entry(ies) re-wrapped"
)
self.assertEqual(wolfdawn.parse_names_wrap_shrink_count(blob), 2)
self.assertEqual(wolfdawn.parse_names_wrap_counts(res.stdout), 7)
self.assertEqual(wolfdawn.parse_names_wrap_shrink_count(res.stdout), 2)
if __name__ == "__main__":

View file

@ -134,27 +134,6 @@ def scale_font_sizes(
return new_text, count
def apply_font_size(text: str, size: int) -> tuple[str, int]:
"""Force every ``\\f[N]`` in *text* to the same *size* (no proportional scale).
Prefer :func:`scale_font_sizes` when the line has emphasis overrides that
should stay relatively larger than the body font.
Always ensures a leading ``\\f[size]``. Returns ``(new_text, replacements)``.
"""
if not isinstance(text, str) or not text.strip() or size <= 0:
return text, 0
size = int(size)
matches = list(WOLF_FONT_SIZE_RE.finditer(text))
if matches:
had_leading = bool(WOLF_FONT_SIZE_RE.match(text))
new_text = WOLF_FONT_SIZE_RE.sub(rf"\\f[{size}]", text)
new_text = ensure_leading_font_size(new_text, size)
count = len(matches) + (0 if had_leading else 1)
return new_text, count
return ensure_leading_font_size(text, size), 1
def _control_code_tokens(text: str) -> list[str]:
"""Return the sequence of WolfDawn-compared control codes in *text*."""
if not isinstance(text, str) or not text:

View file

@ -9,12 +9,11 @@ from __future__ import annotations
import json
import re
from dataclasses import dataclass, field
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
import util.dazedwrap as dazedwrap
from util.wolfdawn.db_classify import group_key, json_file_from_doc
from util.wolfdawn.selective_wrap import line_needs_wrap, wrap_line_text
WRAP_PROFILE_NAME = "wrap_profile.json"
@ -89,22 +88,6 @@ class WrapHit:
)
@dataclass
class SheetOverflowSummary:
json_file: str
sheet_name: str
line_count: int
overflow_count: int
tier: str = ""
kind: str = "db"
@property
def key(self) -> str:
if self.kind == "names":
return f"{self.json_file}|names|{self.sheet_name}"
return group_key(self.json_file, self.sheet_name)
def wrap_profile_path(work_dir: str | Path) -> Path:
return Path(work_dir) / WRAP_PROFILE_NAME
@ -402,20 +385,6 @@ def wrap_preview_info(text: str, width: int) -> dict[str, Any]:
}
def format_wrap_preview(text: str, width: int) -> str:
"""Multiline preview with per-line visible character counts."""
info = wrap_preview_info(text, width)
if not info["wrapped"]:
return ""
lines = info["wrapped"].replace("\r\n", "\n").replace("\r", "\n").split("\n")
parts: list[str] = []
for i, line in enumerate(lines):
vis = dazedwrap.max_line_visible_length(line)
marker = "" if vis > width else " "
parts.append(f"{marker} {i + 1:2d} ({vis:2d}) {line}")
return "\n".join(parts)
def wrap_preview_summary(text: str, width: int) -> str:
"""One-line status for the preview header."""
info = wrap_preview_info(text, width)
@ -578,15 +547,6 @@ def format_wrap_preview_html(
)
def split_line_at_visible_width(line: str, width: int) -> tuple[int, int]:
"""Return ``(fit_end, line_len)`` char indices in *line* for UI highlighting."""
line_len = len(line)
if not line or width <= 0:
return (line_len, line_len)
fit_end = dazedwrap.visible_word_wrap_end_index(line, width)
return (min(fit_end, line_len), line_len)
def locate_line(doc: dict[str, Any], hit_id: dict[str, Any]) -> dict[str, Any] | None:
"""Return the live line dict for *hit_id* inside *doc*."""
kind = hit_id.get("kind")
@ -1081,96 +1041,3 @@ def _wrap_overflow_in_names_category(
if changed:
save_document(path, doc)
return changed
def _names_category_summaries(
doc: dict[str, Any],
*,
json_file: str,
width: int,
) -> list[SheetOverflowSummary]:
counts: dict[str, int] = {}
overflow: dict[str, int] = {}
for entry in doc.get("names") or []:
if not isinstance(entry, dict):
continue
note = str(entry.get("note") or "names.json")
counts[note] = counts.get(note, 0) + 1
text = entry.get("text")
if isinstance(text, str) and line_needs_wrap(text, width):
overflow[note] = overflow.get(note, 0) + 1
return [
SheetOverflowSummary(
json_file=json_file,
sheet_name=note,
line_count=counts[note],
overflow_count=overflow.get(note, 0),
tier="names",
kind="names",
)
for note in sorted(counts)
]
def sheet_overflow_summaries(
translated_dir: str | Path,
width: int,
*,
files_dir: str | Path | None = None,
) -> list[SheetOverflowSummary]:
"""Per DB sheet overflow counts at *width*."""
from util.wolfdawn.db_classify import analyze_content_distribution, classify_db_document
base = Path(translated_dir)
if not base.is_dir():
base = Path(files_dir) if files_dir else base
dist = analyze_content_distribution(base)
summaries: list[SheetOverflowSummary] = []
for path in sorted(base.glob("*.project.json")):
doc = _load_json(path)
if not doc or doc.get("kind") != "db":
continue
jf = path.name
for group in classify_db_document(doc, json_file=jf):
overflow = 0
for g in doc.get("groups") or []:
if str(g.get("typeName") or "") != group.type_name:
continue
for line in g.get("lines") or []:
text = line.get("text")
if isinstance(text, str) and line_needs_wrap(text, width):
overflow += 1
summaries.append(
SheetOverflowSummary(
json_file=jf,
sheet_name=group.type_name,
line_count=group.line_count,
overflow_count=overflow,
tier=group.tier,
)
)
if not summaries:
summaries = [
SheetOverflowSummary(
json_file=g.json_file,
sheet_name=g.type_name,
line_count=g.line_count,
overflow_count=0,
tier=g.tier,
)
for g in dist.groups
]
seen_names: set[Path] = set()
for search_base in _iter_search_dirs(base, Path(files_dir) if files_dir else None):
names_path = search_base / "names.json"
if not names_path.is_file() or names_path in seen_names:
continue
seen_names.add(names_path)
doc = _load_json(names_path)
if doc and doc.get("kind") == "names":
summaries.extend(
_names_category_summaries(doc, json_file="names.json", width=width)
)
return summaries