DazedTL/tests/test_wolf_wrap_search.py
2026-07-09 15:42:53 -05:00

535 lines
21 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()