#!/usr/bin/env python3 """Unit tests for search-first wrap workflow (util/wolfdawn/wrap_search.py).""" from __future__ import annotations import json import os import sys import tempfile import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[1] os.chdir(ROOT) sys.path.insert(0, str(ROOT)) import util.dazedwrap as dazedwrap # noqa: E402 from util.wolfdawn import wrap_search as ws # noqa: E402 RUMOR_SHEET = "├■街の噂(MOB)" GATEKEEPER_TEXT = ( "That gatekeeper just randomly hits on any pretty woman he sees. " "They say he once tried to pick up a horned rabbit." ) GATEKEEPER_ROW = 26 GATEKEEPER_FIELD = "ロ:町のウワサ0" RUMOR_FIXTURE = { "file": "DataBase.project", "kind": "db", "groups": [ { "type": 29, "typeName": RUMOR_SHEET, "lines": [ { "row": 25, "field": 1, "fieldName": "ロ:町のウワサ0", "source": "別の噂", "text": "Short rumor that fits fine.", }, { "row": GATEKEEPER_ROW, "field": 1, "fieldName": GATEKEEPER_FIELD, "source": "門番", "text": GATEKEEPER_TEXT, }, ], } ], } class TestWrapSearch(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.translated = Path(self.tmp.name) / "translated" self.translated.mkdir() self.work = Path(self.tmp.name) / "wolf_json" self.work.mkdir() path = self.translated / "DataBase.project.json" path.write_text(json.dumps(RUMOR_FIXTURE, ensure_ascii=False, indent=4), encoding="utf-8") def tearDown(self): self.tmp.cleanup() 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.sheet_name, RUMOR_SHEET) self.assertEqual(hit.row, GATEKEEPER_ROW) 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) 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) ws.set_sheet_width(self.work, RUMOR_SHEET, 34, json_file="DataBase.project.json") self.assertEqual( ws.get_sheet_width(ws.load_wrap_profile(self.work), RUMOR_SHEET), 34 ) class TestNamesSearchAndWrap(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() self.translated = Path(self.tmp.name) / "translated" self.translated.mkdir() self.category = "├■街の噂(MOB)" fixture = { "kind": "names", "names": [ { "source": "「メイドがどんどん辞めていく」", "text": '"Yeah, maids keep quitting one after another."', "note": self.category, }, { "source": "長い", "text": ( "This is a very long English rumor that should definitely " "wrap when the width is set to thirty-four characters." ), "note": self.category, }, ], } self.path = self.translated / "names.json" self.path.write_text(json.dumps(fixture, ensure_ascii=False, indent=4), encoding="utf-8") def tearDown(self): self.tmp.cleanup() 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_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.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 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_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." ) new_text, changed = ws.apply_manual_font_and_wrap(text, 40, font=14) self.assertTrue(changed) self.assertIn(r"\f[16]", new_text) self.assertIn(r"\f[14]", new_text) self.assertLessEqual(dazedwrap.max_line_visible_length(new_text), 40) short, skipped = ws.apply_manual_font_and_wrap( "Short.", 40, font=14, only_overflow_or_fonts=True ) self.assertFalse(skipped) self.assertEqual(short, "Short.") def test_manual_font_zero_or_none_does_not_add_font(self): text = "A short line that needs wrapping because it is quite long for width thirty." for font in (None, 0): new_text, changed = ws.apply_manual_font_and_wrap(text, 30, font=font) self.assertTrue(changed) self.assertNotIn(r"\f[", new_text) self.assertLessEqual(dazedwrap.max_line_visible_length(new_text), 30) # Existing fonts stay put when font is disabled. with_f = r"\f[18]Hello there this is a longer line for wrapping please." kept, changed = ws.apply_manual_font_and_wrap(with_f, 20, font=None) self.assertTrue(changed) self.assertTrue(kept.startswith(r"\f[18]")) self.assertNotIn(r"\f[14]", kept) def test_manual_reflows_soft_breaks_that_already_fit(self): """Hard \\n lines can each fit width while the preview still reflows.""" text = ( '\\f[13]"I heard there\'s a super hot female\n' "adventurer who came to town recently!?\n" "Maybe I'll catch a glimpse of her at the\n" r'\c[21]\f[14]tavern\c[19]\f[13]!"' ) # Per-line lengths are under 55, so the old overflow check skipped wrap. self.assertFalse(ws.line_needs_wrap(text, 55)) new_text, changed = ws.apply_manual_font_and_wrap(text, 55, font=13) self.assertTrue(changed) self.assertEqual(new_text, ws.wrap_line_text(text, 55)) self.assertIn("female adventurer who came", new_text.replace("\n", " ")) # Already at the target layout → no-op. again, changed_again = ws.apply_manual_font_and_wrap(new_text, 55, font=13) self.assertFalse(changed_again) self.assertEqual(again, new_text) def test_wrap_overflow_manual_in_names_category(self): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "names.json" doc = { "kind": "names", "names": [ { "note": "噂", "source": "a", "text": ( r"So they even take worries like this in the " r"\c[21]\f[20]confessional\c[19]\f[18] booth." ), }, {"note": "噂", "source": "b", "text": "OK"}, {"note": "other", "source": "c", "text": "x" * 80}, ], } path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") hit = ws.WrapHit( json_file="names.json", kind="names", sheet_name="噂", row=0, field_name="name", text=doc["names"][0]["text"], max_line_len=80, ) changed = ws.wrap_overflow_manual_in_scope(path, doc, hit, 36, font=14) # Both 噂 entries get body font (short "OK" included); other category skipped. self.assertEqual(changed, 2) saved = json.loads(path.read_text(encoding="utf-8")) self.assertIn(r"\f[14]", saved["names"][0]["text"]) self.assertEqual(saved["names"][1]["text"], r"\f[14]OK") self.assertEqual(saved["names"][2]["text"], "x" * 80) class TestEventScopeWrap(unittest.TestCase): def test_wrap_all_in_spoken_format_across_scenes(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) long_a = ( "Celria\n" "This is a very long line of dialogue that " "should wrap at thirty four visible chars." ) long_b = ( "Citizen\n" "Another very long line of dialogue that also " "should wrap at thirty four visible chars." ) ui_line = ( "\\f[24]Rosa profile card that is also long enough " "to wrap at thirty four visible chars easily." ) path = root / "CommonEvent.dat.json" doc = { "kind": "common", "file": "CommonEvent.dat", "scenes": [ { "event": 1, "name": "Intro", "lines": [ { "speaker": "セルリア", "speaker_src": "literal_line1_lowconf", "text": long_a, }, ], }, { "event": 2, "name": "Town", "lines": [ { "speaker": "市民", "speaker_src": "literal_line1_lowconf", "text": long_b, }, { "speaker": "UI", "speaker_src": "ui", "text": ui_line, }, ], }, ], } path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") hit = ws.WrapHit( json_file="CommonEvent.dat.json", kind="common", sheet_name="CommonEvent", row=1, field_name="セルリア", text="very long line", max_line_len=80, scene_index=0, line_index=0, ) stats = ws.scope_stats(doc, hit, 34) self.assertEqual(stats.total, 2) self.assertEqual(stats.overflow, 2) self.assertIn("spoken", stats.label) changed = ws.wrap_overflow_in_scope( path, doc, hit, 34, translated_dir=root ) self.assertEqual(changed, 2) saved = json.loads(path.read_text(encoding="utf-8")) a = saved["scenes"][0]["lines"][0]["text"] b = saved["scenes"][1]["lines"][0]["text"] ui = saved["scenes"][1]["lines"][1]["text"] self.assertTrue(a.startswith("Celria\n")) self.assertTrue(b.startswith("Citizen\n")) self.assertNotEqual(a, long_a) self.assertNotEqual(b, long_b) # UI format must stay untouched. self.assertEqual(ui, ui_line) class TestFitTextToBox(unittest.TestCase): """DB Relayout: wrap + font shrink until soft line count fits.""" RUMOR = ( '\\f[14]"I heard there\'s a super hot female adventurer who\n' "came to town recently!? Maybe I'll catch a glimpse\n" 'of her at the \\c[21]\\f[15]tavern\\c[19]\\f[14]!"' ) def test_count_soft_lines(self): self.assertEqual(ws.count_soft_lines("a\nb\nc"), 3) self.assertEqual(ws.count_soft_lines("one line"), 1) self.assertEqual(ws.count_soft_lines(""), 0) def test_max_lines_zero_wraps_only(self): fitted, changed = ws.fit_text_to_box(self.RUMOR, 44, max_lines=0) self.assertTrue(changed) self.assertEqual(ws.count_soft_lines(fitted), 3) self.assertIn(r"\f[14]", fitted) def test_max_lines_shrinks_font_and_fits(self): fitted, changed = ws.fit_text_to_box(self.RUMOR, 44, max_lines=2) self.assertTrue(changed) self.assertLessEqual(ws.count_soft_lines(fitted), 2) from util.wolfdawn import codes as wolf_codes self.assertLess(wolf_codes.infer_base_font_size(fitted), 14) self.assertIn("tavern", fitted) def test_box_font_from_jp_source_scales_width(self): """Native box px from JP source lets more cells fit when \\f shrinks.""" from util.wolfdawn import codes as wolf_codes fitted, changed = ws.fit_text_to_box( self.RUMOR, 44, max_lines=2, box_font=18 ) self.assertTrue(changed) self.assertLessEqual(ws.count_soft_lines(fitted), 2) # With box_font=18, body can stay larger than when native=14. self.assertGreaterEqual(wolf_codes.infer_base_font_size(fitted), 12) fitted2, changed2 = ws.fit_text_to_box( fitted, 44, max_lines=2, box_font=18 ) self.assertFalse(changed2) self.assertEqual(fitted2, fitted) def test_wrap_hit_in_file_honors_max_lines(self): with tempfile.TemporaryDirectory() as tmp: path = Path(tmp) / "DataBase.project.json" source = ( "「最近町に来た女冒険者が激マブらしいぞ!?\n" " \\c[21]\\f[20]酒場\\c[19]\\f[18]に行ったら会えるかな!」" ) doc = { "kind": "db", "file": "DataBase.project", "groups": [ { "typeName": RUMOR_SHEET, "lines": [ { "row": 21, "field": 25, "fieldName": GATEKEEPER_FIELD, "source": source, "text": self.RUMOR, } ], } ], } path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") hit_id = { "kind": "db", "sheet_name": RUMOR_SHEET, "row": 21, "field_name": GATEKEEPER_FIELD, } self.assertTrue( ws.wrap_hit_in_file(path, doc, hit_id, 44, max_lines=2) ) line = ws.locate_line(doc, hit_id) self.assertIsNotNone(line) self.assertLessEqual(ws.count_soft_lines(str(line["text"])), 2) from util.wolfdawn import codes as wolf_codes # JP body is \\f[18]; shrink should land near 12, not floor at 8. self.assertGreaterEqual( wolf_codes.infer_base_font_size(str(line["text"])), 12 ) class TestNameplateWrap(unittest.TestCase): TEXT = ( "Celria\n" "The number of cases involving 'illegal Magic Drugs' and 'monster attacks' " "seems strangely high\n" "for a town of this size." ) def test_preserves_speaker_newline(self): wrapped = ws.wrap_preserving_nameplate( self.TEXT, 50, speaker_src="literal_line1_lowconf", speaker="セルリア", ) self.assertTrue(wrapped.startswith("Celria\n")) self.assertNotIn("Celria The", wrapped) def test_never_wipes_body(self): """Regression: group wrap must not leave ``\\f[N]Name\\n`` with empty body.""" text = ( "Citizen\n" "Spending our tax money however he pleases...... ever since\n" "the Prince took over as regent, it's been nothing but\n" "parties every single night, I tell you......" ) kw = dict(speaker_src="literal_line1_lowconf", speaker="市民") for width, max_lines, font in ( (35, 2, None), (40, 1, None), (21, 2, 21), (50, 0, 18), ): if font is not None: out, _ = ws.apply_manual_font_and_wrap( text, width, font=font, **kw ) else: out, _ = ws.fit_text_to_box( text, width, max_lines=max_lines or None, **kw ) self.assertIn("\n", out, msg=repr(out)) body = out.split("\n", 1)[1] self.assertTrue(body.strip(), msg=f"wiped at w={width}: {out!r}") self.assertIn("Spending", body) self.assertIn("parties", body) def test_fit_max_lines_counts_body_only(self): fitted, changed = ws.fit_text_to_box( self.TEXT, 40, max_lines=2, speaker_src="literal_line1_lowconf", speaker="セルリア", ) self.assertTrue(changed) self.assertTrue(fitted.startswith("Celria\n")) body_lines = ws.count_body_soft_lines( fitted, speaker_src="literal_line1_lowconf", speaker="セルリア", ) self.assertLessEqual(body_lines, 2) def test_manual_group_applies_font_to_short_spoken_lines(self): """Group wrap with body font must not skip short lines that already fit.""" short = "Celria\nJust now...... what did you say?" line = { "text": short, "speaker": "セルリア", "speaker_src": "literal_line1_lowconf", } self.assertTrue(ws._apply_manual_to_line_dict(line, 70, font=21)) self.assertEqual( line["text"], "Celria\n\\f[21]Just now...... what did you say?", ) # Font 0 / None: still skip short plain lines (width-only group wrap). line2 = { "text": short, "speaker": "セルリア", "speaker_src": "literal_line1_lowconf", } self.assertFalse(ws._apply_manual_to_line_dict(line2, 70, font=None)) self.assertEqual(line2["text"], short) def test_dialogue_geometry_roundtrip(self): with tempfile.TemporaryDirectory() as tmp: work = Path(tmp) ws.set_format_geometry( work, "literal_line1_lowconf", width=48, max_lines=3, font=16 ) ws.set_format_geometry(work, "ui", width=28, max_lines=8, font=24) spoken = ws.get_format_geometry( ws.load_wrap_profile(work), "literal_line1" ) ui = ws.get_format_geometry(ws.load_wrap_profile(work), "ui") self.assertEqual(spoken["width"], 48) self.assertEqual(spoken["max_lines"], 3) self.assertEqual(ui["width"], 28) self.assertEqual(ui["font"], 24) # Spoken aliases share one bucket. self.assertEqual( ws.wrap_format_key("literal_line1"), ws.wrap_format_key("literal_line1_lowconf"), ) self.assertNotEqual( ws.wrap_format_key("literal_line1"), ws.wrap_format_key("ui"), ) if __name__ == "__main__": unittest.main()