Files
dify/api/tests/unit_tests/services/dify_builder/test_localize.py
T
Charles YaoandClaude Opus 4.8 28fc7d0fac fix(dify-builder): unwrap the model's translation envelope in the Localizer
Live e2e revealed M2 never translated the static catalog strings: the model
returns the batch-translate mapping wrapped under a "strings" key (echoing the
request envelope) — {"strings": {src: translation}} — so _fill_cache's
top-level table.get(src) missed every source and cached the English original.
Unwrap one level when the sources aren't top-level keys, and tighten the prompt
to ask for a flat object. The unit-test fake now mirrors the real wrapped shape
so this can't regress (a flat fake hid the bug).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-10 17:03:29 -07:00

168 lines
7.3 KiB
Python

"""M2 Localizer: detect language, translate only catalog/template strings, cache."""
import pytest
from core.dify_builder.models import ConversationItem
from services.dify_builder.agent import localize as localize_mod
from services.dify_builder.agent.localize import Localizer
@pytest.fixture(autouse=True)
def _clear_translation_cache():
# _TRANSLATION_CACHE is module-level (shared across Localizer instances so a
# language's finite vocabulary is really translated once process-wide). Tests
# must not leak cache entries into each other, so clear it before every test --
# this keeps e.g. test_localize_caches_translations' translate_calls == 1
# assertion deterministic regardless of test order.
localize_mod._TRANSLATION_CACHE.clear()
yield
localize_mod._TRANSLATION_CACHE.clear()
class _FakeModel:
"""Records calls; returns canned detection / translation payloads."""
def __init__(self):
self.translate_calls = 0
# detect_language uses invoke_text; translate uses invoke_json — patched below.
def _localizer(monkeypatch, *, detect="zh-Hans", table=None):
table = table or {}
from services.dify_builder.agent import localize as mod
def fake_invoke_text(model, *, system, user, **kw): # noqa: ARG001
return detect
def fake_invoke_json(model, *, system, user, **kw): # noqa: ARG001
model.translate_calls += 1
# translate every requested source string via the table (default: prefix).
# Mirror the REAL model behavior observed in the live stack: it wraps the
# mapping under a "strings" key (echoing the request envelope). The
# Localizer must unwrap this; a flat fake would hide that bug.
import json
srcs = json.loads(user)["strings"] if user.strip().startswith("{") else []
return {"strings": {s: table.get(s, f"<{s}>") for s in srcs}}
monkeypatch.setattr(mod.llm, "invoke_text", fake_invoke_text)
monkeypatch.setattr(mod.llm, "invoke_json", fake_invoke_json)
fake = _FakeModel()
return Localizer(lambda: fake), fake
def test_detect_language(monkeypatch):
loc, _ = _localizer(monkeypatch, detect="ja")
assert loc.detect_language("こんにちは") == "ja"
def test_detect_language_english_fallback_on_empty(monkeypatch):
loc, _ = _localizer(monkeypatch)
assert loc.detect_language("") == "en"
def test_detect_language_clean_code_passes(monkeypatch):
loc, _ = _localizer(monkeypatch, detect="ja")
assert loc.detect_language("hello") == "ja"
def test_detect_language_rejects_non_code_reply(monkeypatch):
loc, _ = _localizer(monkeypatch, detect="The language is Japanese.")
assert loc.detect_language("hello") == "en"
def test_localize_translates_catalog_string(monkeypatch):
loc, _ = _localizer(monkeypatch, table={"Test run": "测试运行"})
items = [ConversationItem(kind="test_result", payload={"title": "Test run", "tone": "success"})]
out = loc.localize_items(items, "zh-Hans")
assert out[0].payload["title"] == "测试运行"
assert out[0].payload["tone"] == "success" # enum untouched
def test_localize_leaves_non_catalog_prose_untouched(monkeypatch):
loc, _ = _localizer(monkeypatch)
items = [ConversationItem(kind="assistant_turn", payload={"reply_text": "这是我为你生成的计划"})]
out = loc.localize_items(items, "zh-Hans")
assert out[0].payload["reply_text"] == "这是我为你生成的计划" # LLM prose, not in catalog
def test_localize_template_reinserts_dynamic_part(monkeypatch):
loc, _ = _localizer(monkeypatch, table={"Workflow built ({count} nodes)": "已构建工作流({count} 个节点)"})
items = [ConversationItem(kind="summary", payload={"items": ["Workflow built (3 nodes)"]})]
out = loc.localize_items(items, "zh-Hans")
assert out[0].payload["items"][0] == "已构建工作流(3 个节点)"
def test_localize_english_is_noop(monkeypatch):
loc, fake = _localizer(monkeypatch)
items = [ConversationItem(kind="test_result", payload={"title": "Test run"})]
out = loc.localize_items(items, "en")
assert out[0].payload["title"] == "Test run"
assert fake.translate_calls == 0
def test_localize_caches_translations(monkeypatch):
loc, fake = _localizer(monkeypatch, table={"Review": "评审"})
for _ in range(3):
loc.localize_items([ConversationItem(kind="summary", payload={"title": "Review"})], "zh-Hans")
assert fake.translate_calls == 1 # translated once, cached thereafter
def test_localize_shares_cache_across_instances(monkeypatch):
# The catalog translation cache is module-level: a fresh Localizer per
# advance-task invocation must still hit the cache a prior instance filled.
loc1, fake1 = _localizer(monkeypatch, table={"Review": "评审"})
loc1.localize_items([ConversationItem(kind="summary", payload={"title": "Review"})], "zh-Hans")
assert fake1.translate_calls == 1
loc2, fake2 = _localizer(monkeypatch, table={"Review": "评审"})
out = loc2.localize_items([ConversationItem(kind="summary", payload={"title": "Review"})], "zh-Hans")
assert out[0].payload["title"] == "评审"
assert fake2.translate_calls == 0 # second instance reused the module-level cache
def test_detect_language_provider_raising_falls_back_to_en():
# Env callbacks must not raise (see localize.Localizer usage in the engine
# Env); a model_provider that raises must degrade to the "en" fallback
# instead of propagating out of detect_language.
def raising_provider():
raise RuntimeError("boom")
loc = Localizer(raising_provider)
assert loc.detect_language("hello") == "en"
def test_fill_cache_provider_raising_leaves_strings_untranslated():
# Same contract for the translate path: a raising model_provider must not
# propagate out of localize_items -- it should degrade to a no-op (original
# strings kept).
def raising_provider():
raise RuntimeError("boom")
loc = Localizer(raising_provider)
items = [ConversationItem(kind="summary", payload={"title": "Review"})]
out = loc.localize_items(items, "zh-Hans")
assert out[0].payload["title"] == "Review"
def test_unwrap_translation_table():
misses = ["Review", "Test run"]
flat = {"Review": "评审", "Test run": "测试运行"}
# flat mapping: returned as-is
assert Localizer._unwrap_translation_table(flat, misses) == flat
# wrapped envelope (real model behavior): descend one level
wrapped = {"strings": flat}
assert Localizer._unwrap_translation_table(wrapped, misses) == flat
# differently-named wrapper key still unwraps by content
assert Localizer._unwrap_translation_table({"translations": flat}, misses) == flat
# non-dict / unrecognizable: returns a dict with no matching keys (safe fallback to English upstream)
assert Localizer._unwrap_translation_table([], misses) == {}
def test_localize_translates_when_model_wraps_response(monkeypatch):
# End-to-end within the Localizer: the fake now mirrors the real model's
# {"strings": {...}} envelope, so this only passes if _fill_cache unwraps it.
loc, _ = _localizer(monkeypatch, table={"Let's clarify the requirements.": "让我们明确一下需求。"})
items = [ConversationItem(kind="assistant_turn", payload={"reply_text": "Let's clarify the requirements."})]
out = loc.localize_items(items, "zh-Hans")
assert out[0].payload["reply_text"] == "让我们明确一下需求。"