fix(i18n): đổi ngôn ngữ áp lại toàn bộ chữ trên màn hình
Trước đây rất nhiều widget gọi setText(tr(...)) một lần lúc dựng, nên sau khi đổi ngôn ngữ một nửa màn hình vẫn giữ tiếng cũ. Thêm họ bind_text/bind_tip/ bind_placeholder/bind_items/bind_dynamic trong i18n: khoá dịch được gắn thẳng vào widget (giữ tham chiếu yếu) và tự áp lại mỗi lần set_language. - co4e sidebar, node property panel, run control, routing toggle, nav rail, top bar, filter scaffold, usage chart: chuyển sang binding hoặc tự đăng ký on_language_changed thay vì trông chờ nơi nhúng. - RoutingToggle/AutoRunToggle tự đăng ký retranslate và dùng AdjustToContents để bản dịch dài không bị cắt. - BusyOverlay mới: che cửa sổ trong lúc set_language chạy đồng bộ trên GUI thread, tránh cảm giác treo app. - co4e sidebar chỉ đọc thư viện skill một lần (_skill_prefix_lookup) thay vì quét đĩa cho từng skill — trước đó mỗi lần reload tốn ~3.8s đứng GUI. - Bổ sung bản dịch ja/vi còn để nguyên tiếng Anh; sửa chiều cao hàng Settings ở nav rail. - Thêm 4 bộ test UI cho các thay đổi trên. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
thanhnv
co-authored by
Claude Opus 5
parent
58a2a4507d
commit
9459dbe197
@@ -0,0 +1,174 @@
|
||||
"""Co4E left column: the skill library must be read ONCE per sidebar reload.
|
||||
|
||||
Why this test exists
|
||||
--------------------
|
||||
``Co4ESidebarMixin._reload_sidebar`` used to call
|
||||
``core.skills.skill_prefix_for(name)`` once per skill. Every one of those calls
|
||||
re-reads the *whole* skill folder from disk (``list_skills()`` +
|
||||
``builtin_skills()``), so the reload was O(N**2) in the number of skills.
|
||||
|
||||
That reload runs on every language switch (``Co4ETab._retranslate`` ->
|
||||
``_reload_sidebar``). Measured end-to-end on a real library of 121 skills, the
|
||||
per-name calls cost ~3.8 s of blocked GUI thread — the "app freezes for a few
|
||||
seconds when I switch Vietnamese -> English" the user reported.
|
||||
|
||||
Two different things are guarded here, and they fail for different reasons:
|
||||
|
||||
* ``test_reload_sidebar_reads_the_skill_library_once`` — the performance
|
||||
contract. Red before the fix (the library was read 1 + N times), green after.
|
||||
* the two equivalence tests — the *correctness* contract. A lookup table is
|
||||
easy to build subtly wrong, and a wrong one silently changes which skill text
|
||||
is pushed into an agent prompt. They pin the answer to ``skill_prefix_for``
|
||||
itself in exactly the cases where a naive ``{name: skill}`` dict diverges:
|
||||
a name shared by a user skill and a built-in (first match wins, user first),
|
||||
a skill whose instructions are blank (it does NOT end ``skill_prefix_for``'s
|
||||
scan, so a later namesake with real instructions must still win), and
|
||||
lookups by slug / different case / padding / unknown name / empty name.
|
||||
Those two are characterization tests: green before AND after by design.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QListWidget
|
||||
|
||||
from cowork_local.core import co4e, skills as skills_mod
|
||||
from cowork_local.presentation.co4e.co4e_sidebar import Co4ESidebarMixin
|
||||
|
||||
|
||||
class _SidebarHarness(Co4ESidebarMixin):
|
||||
"""Just enough state to run the real ``_reload_sidebar``.
|
||||
|
||||
Building a whole ``Co4ETab`` would drag in config/HOME-dependent module
|
||||
constants (see ``tests/test_build_co4e_tab.py``); the mixin only touches
|
||||
the three list widgets, so the real production method runs unchanged here.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Create the three palette lists ``_reload_sidebar`` fills in."""
|
||||
self.wf_list = QListWidget()
|
||||
self.agent_list = QListWidget()
|
||||
self.skill_list = QListWidget()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def harness(qapp, monkeypatch):
|
||||
"""A sidebar harness with the workflow/agent sources stubbed out empty."""
|
||||
monkeypatch.setattr(co4e, "list_workflows", lambda: [])
|
||||
monkeypatch.setattr(co4e, "list_custom_agents", lambda: [])
|
||||
return _SidebarHarness()
|
||||
|
||||
|
||||
def _install_skills(monkeypatch, user, builtin):
|
||||
"""Replace the two disk-reading skill sources and count how often they run.
|
||||
|
||||
Fresh copies are handed out on every call so a caller mutating a returned
|
||||
``Skill`` cannot make a later call look different.
|
||||
"""
|
||||
calls = {"list_skills": 0, "builtin_skills": 0}
|
||||
|
||||
def _list_skills(directory=None):
|
||||
"""Stand-in for ``skills.list_skills`` that records each read."""
|
||||
calls["list_skills"] += 1
|
||||
return [replace(s) for s in user]
|
||||
|
||||
def _builtin_skills():
|
||||
"""Stand-in for ``skills.builtin_skills`` that records each read."""
|
||||
calls["builtin_skills"] += 1
|
||||
return [replace(s) for s in builtin]
|
||||
|
||||
monkeypatch.setattr(skills_mod, "list_skills", _list_skills)
|
||||
monkeypatch.setattr(skills_mod, "builtin_skills", _builtin_skills)
|
||||
return calls
|
||||
|
||||
|
||||
def _skill_rows(sidebar):
|
||||
"""Return ``(item text, payload instructions)`` for every skill palette row."""
|
||||
return [(sidebar.skill_list.item(i).text(),
|
||||
sidebar.skill_list.item(i).data(Qt.UserRole)["instructions"])
|
||||
for i in range(sidebar.skill_list.count())]
|
||||
|
||||
|
||||
def test_reload_sidebar_reads_the_skill_library_once(harness, monkeypatch):
|
||||
"""One sidebar reload must hit the skill library exactly once, not once per skill."""
|
||||
user = [skills_mod.Skill(name=f"Skill {i}", instructions=f"Body {i}")
|
||||
for i in range(8)]
|
||||
calls = _install_skills(monkeypatch, user, [])
|
||||
|
||||
harness._reload_sidebar()
|
||||
|
||||
assert harness.skill_list.count() == 8, "every skill must still reach the palette"
|
||||
assert calls["list_skills"] == 1, (
|
||||
f"the skill library was read {calls['list_skills']}x for 8 skills — "
|
||||
"reading it once per skill is the O(N^2) freeze on language switch"
|
||||
)
|
||||
assert calls["builtin_skills"] == 1, (
|
||||
f"built-in skills were read {calls['builtin_skills']}x for 8 skills"
|
||||
)
|
||||
|
||||
|
||||
def test_skill_payloads_match_skill_prefix_for(harness, monkeypatch):
|
||||
"""The palette payload must be byte-identical to ``skill_prefix_for``'s answer.
|
||||
|
||||
The fixture is deliberately hostile: a name shared by a user skill and a
|
||||
built-in, a blank-instructions skill followed by a namesake with real
|
||||
content, and names with padding/odd casing.
|
||||
"""
|
||||
user = [
|
||||
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
|
||||
skills_mod.Skill(name="Blank First", instructions=" "),
|
||||
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
|
||||
skills_mod.Skill(name=" Padded Name ", instructions="PADDED BODY"),
|
||||
skills_mod.Skill(name="MiXeD CaSe", instructions="MIXED BODY"),
|
||||
]
|
||||
builtin = [
|
||||
skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION"),
|
||||
skills_mod.Skill(name="Builtin Only", instructions="BUILTIN BODY"),
|
||||
]
|
||||
_install_skills(monkeypatch, user, builtin)
|
||||
|
||||
harness._reload_sidebar()
|
||||
|
||||
expected = [(s.name, skills_mod.skill_prefix_for(s.name)) for s in user + builtin]
|
||||
assert _skill_rows(harness) == expected
|
||||
|
||||
rows = dict(_skill_rows(harness))
|
||||
# A user skill wins over a built-in of the same name: list_skills() comes
|
||||
# first and skill_prefix_for() returns the FIRST match, not the last.
|
||||
assert rows["Shared Name"] == "## Skill: Shared Name\nUSER VERSION"
|
||||
# A blank-instructions skill does not end the scan, so its later namesake
|
||||
# supplies the text. A last-write-wins dict would answer '' here.
|
||||
assert rows["Blank First"] == "## Skill: Blank First\nLATER, WITH CONTENT"
|
||||
assert rows["Builtin Only"] == "## Skill: Builtin Only\nBUILTIN BODY"
|
||||
|
||||
|
||||
def test_lookup_matches_skill_prefix_for_on_odd_names(monkeypatch):
|
||||
"""Slug / case / padding / unknown / empty lookups must answer like ``skill_prefix_for``."""
|
||||
# Imported inside the test on purpose: this symbol is what the fix
|
||||
# introduces, and a module-level import would turn the pre-fix run into a
|
||||
# collection error instead of a real assertion failure in the test above.
|
||||
from cowork_local.presentation.co4e.co4e_sidebar import _skill_prefix_lookup
|
||||
|
||||
user = [
|
||||
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
|
||||
skills_mod.Skill(name="Blank First", instructions=""),
|
||||
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
|
||||
skills_mod.Skill(name="Tiếng Việt / 日本語", instructions="UNICODE BODY"),
|
||||
skills_mod.Skill(name="", instructions="NAMELESS BODY"),
|
||||
]
|
||||
builtin = [skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION")]
|
||||
_install_skills(monkeypatch, user, builtin)
|
||||
|
||||
lookup = _skill_prefix_lookup(skills_mod.list_skills() + skills_mod.builtin_skills())
|
||||
|
||||
probes = [
|
||||
"Shared Name", "shared name", "SHARED NAME", " Shared Name ",
|
||||
"shared-name", # by slug
|
||||
"Blank First", "blank-first",
|
||||
"Tiếng Việt / 日本語", "tiếng-việt-日本語",
|
||||
"", " ", "no-such-skill-anywhere", "skill",
|
||||
]
|
||||
for probe in probes:
|
||||
assert lookup(probe) == skills_mod.skill_prefix_for(probe), probe
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Đổi ngôn ngữ thì MỌI chữ trên màn hình phải đổi theo.
|
||||
|
||||
Lỗi mà bộ test này khoá lại: `w.setToolTip(tr("k"))` chỉ đúng ở đúng thời điểm
|
||||
chạy dòng đó. Không có gì áp lại, nên sau khi người dùng đổi ngôn ngữ thì chữ
|
||||
đứng nguyên ở ngôn ngữ cũ. Vì `DEFAULT_LANGUAGE = "vi"`, triệu chứng người dùng
|
||||
báo là "chọn English mà nhiều chỗ vẫn tiếng Việt".
|
||||
|
||||
Cách kiểm: KHÔNG so chữ với bảng dịch — làm thế thì một nhãn tiếng Việt trùng
|
||||
chữ với nhãn khác sẽ báo oan (và ngược lại, "OneDrive" giống nhau ở cả ba ngôn
|
||||
ngữ sẽ lọt). Thay vào đó thay `tr()` bằng hàm trả về một chuỗi MỐC, rồi gọi
|
||||
`set_language()`. Chỗ nào được áp lại sẽ mang mốc; chỗ nào không mang mốc là chỗ
|
||||
không có ai áp lại — đó chính là lỗi, và biết chắc chứ không phải suy đoán.
|
||||
|
||||
Hai điểm mà một bản kiểm ngây thơ sẽ sai:
|
||||
|
||||
* `from ...i18n import tr` COPY tham chiếu vào namespace của từng module, nên
|
||||
sửa `i18n.tr` một mình là không đủ — phải thay ở mọi module đã import nó.
|
||||
* Lưới vẽ lại bằng `deleteLater()` để lại widget cũ còn sống tới khi vòng lặp sự
|
||||
kiện tiêu hoá xong. Không `sendPostedEvents(DeferredDelete)` thì hàng chục
|
||||
widget bóng ma mang chữ cũ sẽ bị đếm là lỗi trong khi người dùng không hề thấy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
|
||||
|
||||
MOC = "\u2063" # invisible separator: không chuỗi hiển thị thật nào chứa nó
|
||||
|
||||
#: Khoá cố ý giống nhau ở cả ba ngôn ngữ: tên thương hiệu, tên sản phẩm, ký hiệu,
|
||||
#: placeholder thuần định dạng, và mã mức độ hiển thị nguyên dạng. Thêm khoá mới
|
||||
#: vào đây phải kèm lý do — đây là chỗ dễ dùng để lặng lẽ bỏ qua việc dịch.
|
||||
KHOA_KHONG_CAN_DICH = {
|
||||
# thương hiệu / tên sản phẩm
|
||||
"app.logo", "app.credit", "login.header",
|
||||
"app.tab.code", "app.tab.cowork", "app.tab.structure",
|
||||
"code.title", "code.onedrive_badge", "code.onedrive_btn",
|
||||
"cowork.title", "sidebar.filter.code", "sidebar.filter.cowork",
|
||||
"workspace.tab_co4e", "workspace.tab_co4e_tooltip", "workspace.tab_cowork",
|
||||
"workspace.tab_graphrag", "structure.cmem_ui_open",
|
||||
"settings.group.anthropic", "settings.group.cowork", "settings.group.structure",
|
||||
"settings.group.teams", "settings.history_onedrive",
|
||||
"settings.ms365_connector.onedrive", "settings.ms365_connector.outlook",
|
||||
"settings.ms365_connector.sharepoint", "settings.ms365_connector.teams",
|
||||
"schedtask.stepexec.co4e", "schedtask.stepexec.cowork",
|
||||
"schedtask.type.co4e_code", "schedtask.type.cowork",
|
||||
"monitoring.tab_mcp", "ext.mode_rest", "schedtask.f_cron",
|
||||
"help_agent.badge", "help_agent.title", # dùng nguyên dạng trong cả câu tiếng Nhật
|
||||
"help_agent.default_user", # đứng thay cho TÊN người dùng
|
||||
# viết tắt / ký hiệu / thuần định dạng
|
||||
"accounts.ai_search_btn", "monitoring.ai_filter_btn", "monitoring.overview_res_cpu",
|
||||
"monitoring.na", "schedtask.add_link_label", "settings.teams_webhook",
|
||||
"cowork.project_label", "connectors.jira_fail", "tools_admin.jira_fail",
|
||||
# mã mức độ: giữ nguyên dạng ở CẢ BA ngôn ngữ, có chủ ý
|
||||
"monitoring.severity_critical", "monitoring.severity_info", "monitoring.severity_medium",
|
||||
}
|
||||
|
||||
|
||||
# ---- dữ liệu: bảng dịch không được thiếu tiếng Nhật ----------------------
|
||||
|
||||
def test_moi_khoa_co_du_ba_ngon_ngu():
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
thieu = {k: sorted({"en", "ja", "vi"} - {x for x in v if v.get(x)})
|
||||
for k, v in STRINGS.items()
|
||||
if not all(v.get(x) for x in ("en", "ja", "vi"))}
|
||||
|
||||
assert thieu == {}, f"khoá thiếu bản dịch: {thieu}"
|
||||
|
||||
|
||||
def test_ban_dich_nhat_khong_phai_chuoi_tieng_anh():
|
||||
"""`ja` bằng đúng `en` nghĩa là khoá đó chưa được dịch — trừ danh sách miễn.
|
||||
|
||||
Đây là nửa còn lại của lỗi người dùng báo: "chọn tiếng Nhật thì vài chỗ hiển
|
||||
thị tiếng Anh". Nó KHÔNG phải lỗi dây nối (widget vẫn áp lại `tr()` đúng),
|
||||
mà là lỗ trong dữ liệu dịch — nên phải có cổng riêng canh.
|
||||
"""
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
chua_dich = sorted(k for k, v in STRINGS.items()
|
||||
if v.get("en") == v.get("ja") and k not in KHOA_KHONG_CAN_DICH)
|
||||
|
||||
assert chua_dich == [], (
|
||||
"khoá chưa có bản dịch tiếng Nhật (thêm bản dịch, hoặc khai vào "
|
||||
f"KHOA_KHONG_CAN_DICH kèm lý do): {chua_dich}")
|
||||
|
||||
|
||||
def test_danh_sach_mien_khong_phinh_len_am_tham():
|
||||
"""Danh sách miễn chỉ được chứa khoá THẬT SỰ giống nhau ở en/ja.
|
||||
|
||||
Không có test này thì cách dễ nhất để làm cổng trên xanh lại là nhét khoá
|
||||
vào danh sách miễn rồi để nguyên đó sau khi đã dịch.
|
||||
"""
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
thua = sorted(k for k in KHOA_KHONG_CAN_DICH
|
||||
if k in STRINGS and STRINGS[k].get("en") != STRINGS[k].get("ja"))
|
||||
|
||||
assert thua == [], f"khoá đã có bản dịch nhưng vẫn nằm trong danh sách miễn: {thua}"
|
||||
|
||||
|
||||
# ---- lúc chạy: đổi ngôn ngữ thì chữ trên màn hình phải đổi --------------
|
||||
|
||||
def _chu_cua(w):
|
||||
"""Mọi chỗ chữ hiện ra từ một widget, kèm tên thuộc tính."""
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QLineEdit, QListWidget, QPlainTextEdit, QTabWidget,
|
||||
QTableWidget, QTextEdit, QTreeWidget,
|
||||
)
|
||||
ra = []
|
||||
|
||||
def them(ten, s):
|
||||
if isinstance(s, str) and s.strip():
|
||||
ra.append((ten, s))
|
||||
|
||||
# QLineEdit.text() là NỘI DUNG người dùng gõ, không phải nhãn giao diện.
|
||||
if hasattr(w, "text") and not isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit)):
|
||||
try:
|
||||
them("text", w.text())
|
||||
except RuntimeError:
|
||||
return ra
|
||||
for ten in ("windowTitle", "placeholderText", "toolTip", "title", "accessibleName"):
|
||||
f = getattr(w, ten, None)
|
||||
if callable(f):
|
||||
try:
|
||||
them(ten, f())
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
if isinstance(w, QTabWidget):
|
||||
for i in range(w.count()):
|
||||
them(f"tabText[{i}]", w.tabText(i))
|
||||
them(f"tabToolTip[{i}]", w.tabToolTip(i))
|
||||
if isinstance(w, QComboBox):
|
||||
for i in range(w.count()):
|
||||
them(f"itemText[{i}]", w.itemText(i))
|
||||
if isinstance(w, QListWidget):
|
||||
for i in range(w.count()):
|
||||
it = w.item(i)
|
||||
if it is not None:
|
||||
them(f"item[{i}]", it.text())
|
||||
them(f"itemTip[{i}]", it.toolTip())
|
||||
if isinstance(w, QTableWidget):
|
||||
for c in range(w.columnCount()):
|
||||
h = w.horizontalHeaderItem(c)
|
||||
if h is not None:
|
||||
them(f"hheader[{c}]", h.text())
|
||||
if isinstance(w, QTreeWidget):
|
||||
hi = w.headerItem()
|
||||
if hi is not None:
|
||||
for c in range(w.columnCount()):
|
||||
them(f"hheader[{c}]", hi.text(c))
|
||||
return ra
|
||||
|
||||
|
||||
def _chu_so_huu(w):
|
||||
"""Lớp widget của chính dự án gần nhất trên đường đi lên.
|
||||
|
||||
Một `QPushButton` trần không cho biết nó thuộc màn nào; tổ tiên gần nhất do
|
||||
dự án định nghĩa thì cho biết, và đó là file cần sửa.
|
||||
"""
|
||||
cur = w
|
||||
while cur is not None:
|
||||
mod = type(cur).__module__ or ""
|
||||
if mod.startswith("cowork_local"):
|
||||
return f"{mod}.{type(cur).__name__}"
|
||||
cur = cur.parent()
|
||||
return "?"
|
||||
|
||||
|
||||
def _chup(root, giu):
|
||||
"""Ảnh chụp mọi chỗ chữ trong cây widget.
|
||||
|
||||
``giu`` nhận thêm tham chiếu tới từng widget đã đi qua: wrapper PySide6 bị
|
||||
thu hồi thì `id()` được cấp lại cho wrapper sau, và vòng quét sẽ tưởng đã
|
||||
thăm rồi mà dừng sớm — mất phần lớn cây.
|
||||
"""
|
||||
from PySide6.QtWidgets import QMenu, QWidget
|
||||
|
||||
anh, ngan, da_tham = {}, [root], set()
|
||||
while ngan:
|
||||
w = ngan.pop()
|
||||
if id(w) in da_tham:
|
||||
continue
|
||||
da_tham.add(id(w))
|
||||
giu.append(w)
|
||||
so_huu = _chu_so_huu(w)
|
||||
for ten, s in _chu_cua(w):
|
||||
anh[(id(w), type(w).__name__, so_huu, ten)] = s
|
||||
try:
|
||||
ngan.extend([c for c in w.children() if isinstance(c, (QWidget, QMenu))])
|
||||
except RuntimeError:
|
||||
pass
|
||||
return anh
|
||||
|
||||
|
||||
def _thay_tr(moi, cu):
|
||||
"""Thay `tr` ở MỌI module của dự án đang trỏ tới ``cu``. Trả về số module."""
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
n = 0
|
||||
for ten, m in list(sys.modules.items()):
|
||||
if ten.startswith("cowork_local") and getattr(m, "tr", None) is cu:
|
||||
setattr(m, "tr", moi)
|
||||
n += 1
|
||||
i18n_mod.tr = moi
|
||||
return n
|
||||
|
||||
|
||||
def _tieu_hoa(qapp):
|
||||
"""Cho Qt xử lý hết `deleteLater()` để không đếm widget bóng ma."""
|
||||
from PySide6.QtCore import QEvent
|
||||
|
||||
for _ in range(3):
|
||||
qapp.processEvents()
|
||||
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cua_so(qapp, tmp_path):
|
||||
"""`MainWindow` thật, đã dựng cả bốn trang dựng-lười.
|
||||
|
||||
Trang chưa dựng thì không có chữ nào để kiểm, mà đó lại đúng là nơi lỗi hay
|
||||
nằm — nên phải gọi `_ensure_page` cho hết.
|
||||
"""
|
||||
from cowork_local.presentation.shell.bootstrap import build_config, build_context
|
||||
from cowork_local.presentation.shell.main_window import MainWindow
|
||||
|
||||
duong_dan = tmp_path / "config.json"
|
||||
build_config(duong_dan)
|
||||
win = MainWindow(build_context(duong_dan))
|
||||
win.resize(1500, 950)
|
||||
for row in range(len(win._page_widgets)):
|
||||
win._ensure_page(row)
|
||||
yield win
|
||||
win.close()
|
||||
|
||||
|
||||
def test_doi_ngon_ngu_thi_khong_con_chu_ngon_ngu_cu(qapp, cua_so):
|
||||
"""Không chỗ nào giữ lại chữ của ngôn ngữ cũ sau khi đổi ngôn ngữ."""
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
giu = [] # chống cấp lại id() cho wrapper mới
|
||||
goc = i18n_mod.tr
|
||||
ngon_ngu_cu = i18n_mod.get_language()
|
||||
i18n_mod.set_language("vi")
|
||||
_tieu_hoa(qapp)
|
||||
truoc = _chup(cua_so, giu)
|
||||
|
||||
def moc(key, **kw):
|
||||
return MOC + key
|
||||
|
||||
try:
|
||||
assert _thay_tr(moc, goc) > 0, "không thay được tr() ở module nào"
|
||||
i18n_mod.set_language("en")
|
||||
_tieu_hoa(qapp)
|
||||
sau = _chup(cua_so, giu)
|
||||
finally:
|
||||
_thay_tr(goc, moc)
|
||||
i18n_mod.set_language(ngon_ngu_cu)
|
||||
|
||||
# Chỉ tính chỗ mà chữ CÓ phụ thuộc ngôn ngữ: "OneDrive" giống nhau ở cả ba
|
||||
# ngôn ngữ nên không áp lại cũng không ai thấy khác.
|
||||
phu_thuoc_ngon_ngu = set()
|
||||
for v in i18n_mod.STRINGS.values():
|
||||
gia_tri = {v.get("en"), v.get("ja"), v.get("vi")}
|
||||
if len(gia_tri) > 1:
|
||||
phu_thuoc_ngon_ngu.update(x for x in gia_tri if x)
|
||||
|
||||
# Nhãn nhà cung cấp là DANH TÍNH lấy từ ``config.PROVIDER_LABELS``, không đi
|
||||
# qua ``tr()`` ở bất kỳ đâu (Agents Admin, model resolver, preview_ai đều
|
||||
# hiện nguyên dạng như nhau). Một trong số chúng tình cờ trùng chữ với bản
|
||||
# tiếng Anh của ``settings.group.openai`` — là tiêu đề mục trong Cài đặt,
|
||||
# một khoá khác hẳn. Không trừ ra thì phép đo báo oan chỗ này mãi mãi.
|
||||
# Muốn dịch tên nhà cung cấp thì phải sửa ``config.py`` và sửa đồng loạt cả
|
||||
# năm nơi đang đọc nó — đó là một quyết định sản phẩm, không phải lỗi dây nối.
|
||||
from cowork_local.config import PROVIDER_LABELS
|
||||
phu_thuoc_ngon_ngu -= set(PROVIDER_LABELS.values())
|
||||
|
||||
con_chu_cu = sorted(
|
||||
f"{cho[2]} :: {cho[1]}.{cho[3]} = {chu!r}"
|
||||
for cho, chu in sau.items()
|
||||
if cho in truoc and not chu.startswith(MOC) and chu in phu_thuoc_ngon_ngu
|
||||
)
|
||||
|
||||
assert con_chu_cu == [], (
|
||||
"những chỗ này không được áp lại khi đổi ngôn ngữ — dùng bind_text / "
|
||||
"bind_tip / bind_placeholder / bind_items / bind_dynamic của i18n thay "
|
||||
"cho setText(tr(...)) một lần:\n " + "\n ".join(con_chu_cu))
|
||||
|
||||
|
||||
def test_phep_do_thuc_su_quet_duoc_man_hinh(qapp, cua_so):
|
||||
"""Chốt độ phủ của chính phép đo trên.
|
||||
|
||||
Một hôm nào đó `_chup` đi sai (đúng lỗi `id()` bị cấp lại đã gặp) thì nó vẫn
|
||||
trả về một dict rỗng và test trên vẫn xanh — xanh vì không kiểm gì cả.
|
||||
"""
|
||||
giu = []
|
||||
anh = _chup(cua_so, giu)
|
||||
|
||||
assert len(anh) > 500, f"chỉ quét được {len(anh)} chỗ chữ — phép đo đang bị cắt"
|
||||
|
||||
|
||||
def test_binding_tu_don_khi_widget_bi_xoa(qapp):
|
||||
"""Ràng buộc không được giữ widget sống, và không được ném khi widget chết.
|
||||
|
||||
Đây là điều kiện để `bind_*` dùng được cho widget dựng lại liên tục (hàng
|
||||
Kanban, ô lịch) mà không tích thành rác.
|
||||
"""
|
||||
from PySide6.QtWidgets import QLabel
|
||||
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
lbl = QLabel()
|
||||
i18n_mod.bind_text(lbl, "app.settings")
|
||||
truoc = len(i18n_mod._bindings)
|
||||
assert truoc > 0
|
||||
|
||||
lbl.deleteLater()
|
||||
del lbl
|
||||
_tieu_hoa(qapp)
|
||||
i18n_mod._apply_bindings() # không được ném
|
||||
|
||||
assert len(i18n_mod._bindings) < truoc, "ràng buộc của widget đã xoá vẫn còn"
|
||||
@@ -0,0 +1,389 @@
|
||||
"""Đổi ngôn ngữ phải có lớp phủ "đang xử lý" — và chỉ khi nó đáng có.
|
||||
|
||||
``set_language()`` chạy ĐỒNG BỘ trên GUI thread: nó gọi lần lượt mọi callback
|
||||
đã đăng ký qua ``on_language_changed``. Trong lúc đó không có vòng lặp sự kiện,
|
||||
nên không ``QTimer`` nào bắn được và không thanh tiến trình nào quay được — lớp
|
||||
phủ phải được ``repaint()`` NGAY, không phải ``update()``.
|
||||
|
||||
Cùng lý do đó, lớp phủ không thể "đợi xem có chậm không rồi mới hiện": khi việc
|
||||
chặn đã bắt đầu thì không còn ai chạy để bật nó lên. Thứ duy nhất đo được là
|
||||
LẦN ĐỔI TRƯỚC. Vì vậy chính sách là: lần đầu trong phiên thì luôn hiện (thận
|
||||
trọng), các lần sau chỉ hiện khi lần trước đã đủ chậm để người ta kịp nhận ra.
|
||||
Máy nhanh vì thế nháy đúng một lần rồi thôi, thay vì nháy mãi mãi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("PySide6", reason="cần PySide6 để dựng widget thật")
|
||||
|
||||
from cowork_local.presentation.shell import top_bar as top_bar_mod
|
||||
|
||||
|
||||
class _Ctx:
|
||||
"""Ngữ cảnh tối thiểu mà ``_switch_language``/``_open_settings`` cần."""
|
||||
|
||||
def __init__(self, language: str = "vi") -> None:
|
||||
self.config = type("C", (), {})()
|
||||
self.config.language = language
|
||||
self.config.active_provider = "p"
|
||||
self.config.theme = "dark"
|
||||
self.config.data = {}
|
||||
self.so_lan_luu = 0
|
||||
|
||||
def save(self) -> None:
|
||||
self.so_lan_luu += 1
|
||||
|
||||
|
||||
class _Any:
|
||||
"""Nuốt mọi lời gọi.
|
||||
|
||||
``_open_settings`` còn làm mới sidebar/cowork/workspace sau khi đổi ngôn
|
||||
ngữ; không thứ nào trong số đó là thứ đang được kiểm ở đây.
|
||||
"""
|
||||
|
||||
def __getattr__(self, name):
|
||||
return _Any()
|
||||
|
||||
def __call__(self, *a, **k):
|
||||
return _Any()
|
||||
|
||||
|
||||
def _dung(qapp):
|
||||
"""Một ``QMainWindow`` thật có mang ``TopBarMixin`` — đúng ``self`` mà mixin
|
||||
nhận trong sản phẩm, nên lớp phủ làm con của cửa sổ chính y như ``Toast``."""
|
||||
from PySide6.QtWidgets import QComboBox, QMainWindow
|
||||
|
||||
class W(top_bar_mod.TopBarMixin, QMainWindow):
|
||||
_THEME_ICONS = {"system": "monitor", "dark": "moon", "light": "sun"}
|
||||
|
||||
w = W()
|
||||
w.resize(900, 700)
|
||||
w.ctx = _Ctx()
|
||||
combo = QComboBox()
|
||||
for code in ("en", "ja", "vi"):
|
||||
combo.addItem(code.upper(), code)
|
||||
combo.setCurrentIndex(combo.findData("vi"))
|
||||
w.language_combo = combo
|
||||
pcombo = QComboBox()
|
||||
pcombo.addItem("P", "p")
|
||||
w.provider_combo = pcombo
|
||||
return w
|
||||
|
||||
|
||||
def _dong_ho(*moc):
|
||||
"""Đồng hồ giả trả lần lượt các mốc thời gian (giây) cho trước."""
|
||||
it = iter(moc)
|
||||
return lambda: next(it)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dat_ngon_ngu():
|
||||
"""Đặt ngôn ngữ hiện tại mà KHÔNG phát tán cho listener.
|
||||
|
||||
``set_language()`` gọi mọi callback đã đăng ký — trong một phiên pytest đầy
|
||||
đủ, danh sách đó chứa widget của các test khác. Ở đây chỉ cần biết ``tr()``
|
||||
đang trả về ngôn ngữ nào.
|
||||
"""
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
cu = i18n_mod._current
|
||||
|
||||
def dat(code: str) -> None:
|
||||
i18n_mod._current = code
|
||||
|
||||
yield dat
|
||||
i18n_mod._current = cu
|
||||
|
||||
|
||||
# ---- lớp phủ bật TRƯỚC khi việc chặn bắt đầu -----------------------------
|
||||
|
||||
def test_overlay_hien_truoc_khi_set_language_chan(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Đi qua đúng đường người dùng bấm: combo ngôn ngữ ở chân nav rail.
|
||||
|
||||
``isHidden()`` chứ không ``isVisible()``: cửa sổ cha chưa ``show()`` thì
|
||||
``isVisible()`` của widget con luôn False, đúng idiom mà
|
||||
``test_graphrag_busy_panel.py:41`` đã dùng.
|
||||
"""
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
|
||||
def probe(lang):
|
||||
ghi["hien"] = not w._lang_busy.isHidden()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
try:
|
||||
w.language_combo.setCurrentIndex(w.language_combo.findData("en"))
|
||||
w._on_language_changed(0)
|
||||
|
||||
assert ghi["hien"] is True, (
|
||||
"lớp phủ bật sau khi GUI thread đã bị chặn thì người dùng không thấy gì")
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_nguoi_dung_that_su_nhin_thay_lop_phu(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Cửa sổ đã hiện thì ``isVisible()`` mới có nghĩa — chốt luôn mức đó."""
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
|
||||
def probe(lang):
|
||||
ghi["hien"] = w._lang_busy.isVisible()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
w.show()
|
||||
try:
|
||||
w._switch_language("en")
|
||||
|
||||
assert ghi["hien"] is True
|
||||
finally:
|
||||
w.hide()
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_overlay_tat_sau_khi_doi_xong(qapp, monkeypatch, dat_ngon_ngu):
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
|
||||
try:
|
||||
w._switch_language("en")
|
||||
|
||||
assert w._lang_busy.isHidden() is True
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_callback_nem_loi_van_tat_overlay(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""``i18n/__init__.py:68`` chỉ nuốt ``RuntimeError``. Một listener ném loại
|
||||
khác sẽ xuyên qua ``set_language()`` — không có ``try/finally`` thì lớp phủ
|
||||
treo lại trên màn hình vĩnh viễn."""
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
|
||||
def probe(lang):
|
||||
raise ValueError("một listener nào đó hỏng")
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
w._switch_language("en")
|
||||
|
||||
assert w._lang_busy.isHidden() is True, "lớp phủ treo lại mãi mãi"
|
||||
assert w.language_combo.isEnabled() is True, "combo khoá lại mãi mãi"
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
# ---- chữ hiện bằng NGÔN NGỮ CŨ -------------------------------------------
|
||||
|
||||
def _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu: str, sang: str) -> str:
|
||||
"""Chữ trên lớp phủ tại thời điểm việc chặn bắt đầu.
|
||||
|
||||
Probe đổi ``_current`` thật, y như ``set_language()`` làm, nên nếu ``tr()``
|
||||
bị gọi SAU lượt đổi thì chữ sẽ ra ngôn ngữ mới và test đỏ.
|
||||
"""
|
||||
from cowork_local import i18n as i18n_mod
|
||||
|
||||
dat_ngon_ngu(tu)
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
|
||||
def probe(lang):
|
||||
i18n_mod._current = lang
|
||||
ghi["chu"] = w._lang_busy.text()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
try:
|
||||
w._switch_language(sang)
|
||||
return ghi["chu"]
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_chu_hien_bang_ngon_ngu_cu(qapp, monkeypatch, dat_ngon_ngu):
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="vi", sang="en")
|
||||
|
||||
assert chu == STRINGS["app.lang.switching"]["vi"]
|
||||
|
||||
|
||||
def test_chu_hien_bang_ngon_ngu_cu_chieu_nguoc(qapp, monkeypatch, dat_ngon_ngu):
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
chu = _chu_luc_chan(qapp, monkeypatch, dat_ngon_ngu, tu="en", sang="vi")
|
||||
|
||||
assert chu == STRINGS["app.lang.switching"]["en"]
|
||||
|
||||
|
||||
def test_i18n_du_ba_ngon_ngu():
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
assert set(STRINGS["app.lang.switching"]) >= {"en", "ja", "vi"}
|
||||
|
||||
|
||||
# ---- hình dạng: phủ kín cửa sổ, khoá combo -------------------------------
|
||||
|
||||
def test_overlay_phu_kin_cua_so(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Phủ nửa vời thì người dùng vẫn bấm được vào phần còn lại."""
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", lambda lang: None)
|
||||
try:
|
||||
w._switch_language("en")
|
||||
|
||||
assert w._lang_busy.geometry() == w.rect()
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_combo_bi_khoa_trong_luc_chan(qapp, monkeypatch, dat_ngon_ngu):
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
|
||||
def probe(lang):
|
||||
ghi["khoa"] = w.language_combo.isEnabled()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
try:
|
||||
w._switch_language("en")
|
||||
|
||||
assert ghi["khoa"] is False
|
||||
assert w.language_combo.isEnabled() is True, "phải mở khoá lại sau khi xong"
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
# ---- chính sách: chỉ nháy một lần trên máy nhanh -------------------------
|
||||
|
||||
def test_lan_dau_trong_phien_luon_hien(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Chưa đo được gì thì nghiêng về phía hiện: một cái nháy còn hơn một lần
|
||||
đơ vài giây không lời giải thích."""
|
||||
from cowork_local.presentation.shell import busy_overlay as busy_mod
|
||||
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
ghi = {}
|
||||
monkeypatch.setattr(top_bar_mod, "set_language",
|
||||
lambda lang: ghi.setdefault("hien", not w._lang_busy.isHidden()))
|
||||
try:
|
||||
assert w._lang_busy_overlay()._last_ms is None
|
||||
w._switch_language("en")
|
||||
|
||||
assert ghi["hien"] is True
|
||||
assert busy_mod._NOTICEABLE_MS > 0
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_lan_truoc_nhanh_thi_khong_hien_nua(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""20 ms thì không ai kịp thấy mình đang đợi — hiện lớp phủ ở đó là tự tạo
|
||||
ra một cái nháy toàn màn hình."""
|
||||
from cowork_local.presentation.shell import busy_overlay as busy_mod
|
||||
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 0.02, 5.0, 5.02))
|
||||
ghi = []
|
||||
monkeypatch.setattr(top_bar_mod, "set_language",
|
||||
lambda lang: ghi.append(not w._lang_busy.isHidden()))
|
||||
try:
|
||||
w._switch_language("en")
|
||||
w._switch_language("ja")
|
||||
|
||||
assert ghi == [True, False]
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_lan_truoc_cham_thi_van_hien(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Máy chậm / thư viện skill lớn: lần trước mất 1 giây thì lần này phải có
|
||||
lớp phủ, không đợi thêm lần nào nữa."""
|
||||
from cowork_local.presentation.shell import busy_overlay as busy_mod
|
||||
|
||||
dat_ngon_ngu("vi")
|
||||
w = _dung(qapp)
|
||||
monkeypatch.setattr(busy_mod, "perf_counter", _dong_ho(0.0, 1.0, 5.0, 6.0))
|
||||
ghi = []
|
||||
monkeypatch.setattr(top_bar_mod, "set_language",
|
||||
lambda lang: ghi.append(not w._lang_busy.isHidden()))
|
||||
try:
|
||||
w._switch_language("en")
|
||||
w._switch_language("ja")
|
||||
|
||||
assert ghi == [True, True]
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
# ---- đường Cài đặt ▸ Chung -----------------------------------------------
|
||||
|
||||
def _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu: str):
|
||||
"""Chạy ``_open_settings`` với hộp thoại bị thay, trả về (window, ghi)."""
|
||||
w = _dung(qapp)
|
||||
w._apply_theme = lambda: None
|
||||
w.theme_btn = _Any()
|
||||
w.cowork = _Any()
|
||||
w.workspace = _Any()
|
||||
w.sidebar = _Any()
|
||||
ghi = {"goi": 0}
|
||||
|
||||
class _Dlg:
|
||||
def __init__(self, ctx, parent):
|
||||
self._ctx = ctx
|
||||
|
||||
def exec(self):
|
||||
self._ctx.config.language = ngon_ngu_sau_khi_luu
|
||||
return True
|
||||
|
||||
def probe(lang):
|
||||
ghi["goi"] += 1
|
||||
ghi["hien"] = not w._lang_busy.isHidden()
|
||||
|
||||
monkeypatch.setattr(top_bar_mod, "SettingsDialog", _Dlg)
|
||||
monkeypatch.setattr(top_bar_mod, "set_language", probe)
|
||||
return w, ghi
|
||||
|
||||
|
||||
def test_duong_cai_dat_cung_co_overlay(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Cài đặt ▸ Chung đổi ngôn ngữ thì cũng chặn GUI thread y hệt combo."""
|
||||
dat_ngon_ngu("vi")
|
||||
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="en")
|
||||
try:
|
||||
w._open_settings()
|
||||
|
||||
assert ghi["goi"] == 1
|
||||
assert ghi["hien"] is True
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
def test_duong_cai_dat_khong_nhay_khi_khong_doi_ngon_ngu(qapp, monkeypatch, dat_ngon_ngu):
|
||||
"""Bấm Lưu mà không đụng tới ngôn ngữ: ``set_language()`` là no-op
|
||||
(``i18n/__init__.py:62``), nên bật lớp phủ ở đây chỉ là một cái nháy."""
|
||||
dat_ngon_ngu("vi")
|
||||
w, ghi = _mo_cai_dat(qapp, monkeypatch, ngon_ngu_sau_khi_luu="vi")
|
||||
try:
|
||||
w._open_settings()
|
||||
|
||||
assert ghi["goi"] == 0, "gọi set_language() cho một lượt đổi không tồn tại"
|
||||
assert getattr(w, "_lang_busy", None) is None
|
||||
finally:
|
||||
w.deleteLater()
|
||||
|
||||
|
||||
# ---- màu lấy từ theme, không hardcode ------------------------------------
|
||||
|
||||
def test_mau_lay_tu_theme():
|
||||
"""Guardrail G4: ngoài ``theme/`` không file nào được đặt tên một màu."""
|
||||
from cowork_local.theme.qss import _TEMPLATE
|
||||
|
||||
assert "QWidget#busyOverlayPanel" in _TEMPLATE.template
|
||||
src = (Path(__file__).resolve().parents[2]
|
||||
/ "presentation" / "shell" / "busy_overlay.py").read_text(encoding="utf-8")
|
||||
assert "setStyleSheet" not in src
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Regression: Định tuyến / Tự chạy phải đổi chữ theo ngôn ngữ runtime (UI-20260907-01).
|
||||
|
||||
Người dùng dựng app ở một ngôn ngữ rồi đổi sang ngôn ngữ khác: chữ trên công tắc
|
||||
"Định tuyến" và ô "Tự chạy" ở tab Cowork vẫn đóng băng ở ngôn ngữ lúc dựng widget.
|
||||
Vì vậy MỌI test ở đây phải theo khuôn **dựng ở X → đổi sang Y → kiểm**: kiểm ngay
|
||||
lúc vừa dựng là vô nghĩa (widget luôn đúng lúc dựng, kể cả khi bản vá sai).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local import i18n
|
||||
from cowork_local.ui.routing_toggle import AutoRunToggle, RoutingToggle
|
||||
|
||||
|
||||
class FakeCtx:
|
||||
"""AppContext tối thiểu; ghi lại MỌI lần ghi để test chứng minh dịch lại không ghi đĩa."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Khởi tạo với sổ ghi rỗng."""
|
||||
self.writes: list = []
|
||||
|
||||
def project_routing_mode(self, surface: str) -> str:
|
||||
"""Chế độ định tuyến của bề mặt đang mở — cố định "manual" cho test."""
|
||||
return "manual"
|
||||
|
||||
def set_project_routing_mode(self, surface: str, mode: str) -> None:
|
||||
"""Ghi lại lần ghi chế độ định tuyến thay vì chạm đĩa."""
|
||||
self.writes.append((surface, mode))
|
||||
|
||||
def project_auto_run(self) -> bool:
|
||||
"""Trạng thái tự chạy của project — cố định True cho test."""
|
||||
return True
|
||||
|
||||
def set_project_auto_run(self, value: bool) -> None:
|
||||
"""Ghi lại lần ghi cờ tự chạy thay vì chạm đĩa."""
|
||||
self.writes.append(("auto_run", value))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def i18n_sach():
|
||||
"""Trả ngôn ngữ VÀ danh sách listener về nguyên trạng.
|
||||
|
||||
``i18n._listeners`` chỉ có đường vào (i18n/__init__.py:100); widget do test
|
||||
dựng sẽ nằm lại đó và bị gọi ở mọi test sau. Khôi phục để test hermetic.
|
||||
"""
|
||||
lang, listeners = i18n.get_language(), list(i18n._listeners)
|
||||
yield
|
||||
i18n._listeners[:] = listeners
|
||||
i18n.set_language(lang)
|
||||
|
||||
|
||||
def _chu_dinh_tuyen_phai_la(toggle: RoutingToggle, lang: str) -> None:
|
||||
"""Mọi chữ trên công tắc định tuyến phải khớp bản dịch của ``lang``.
|
||||
|
||||
So với ``i18n.STRINGS`` chứ không hard-code chuỗi: đổi từ ngữ sau này không
|
||||
được làm test đỏ oan.
|
||||
"""
|
||||
assert toggle._label.text() == i18n.STRINGS["routing.toggle_label"][lang]
|
||||
assert toggle._combo.toolTip() == i18n.STRINGS["routing.toggle_tooltip"][lang]
|
||||
for i, (_value, key) in enumerate(toggle._modes):
|
||||
assert toggle._combo.itemText(i) == i18n.STRINGS[key][lang], (
|
||||
f"item {i} sai ngôn ngữ"
|
||||
)
|
||||
|
||||
|
||||
def _on_dinh_layout(app, lap: int = 3) -> None:
|
||||
"""Ép Qt xử lý hết layout request đang xếp hàng để bề rộng widget ổn định."""
|
||||
from PySide6.QtCore import QEvent
|
||||
|
||||
for _ in range(lap):
|
||||
app.sendPostedEvents(None, QEvent.LayoutRequest)
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _be_rong_o_chu(combo) -> int:
|
||||
"""Bề rộng vùng hiển thị chữ của combo (đã trừ khung và mũi tên)."""
|
||||
from PySide6.QtWidgets import QStyle, QStyleOptionComboBox
|
||||
|
||||
opt = QStyleOptionComboBox()
|
||||
opt.initFrom(combo)
|
||||
opt.currentText = combo.currentText()
|
||||
opt.editable = combo.isEditable()
|
||||
opt.frame = combo.hasFrame()
|
||||
opt.subControls = QStyle.SC_All
|
||||
return combo.style().subControlRect(
|
||||
QStyle.CC_ComboBox, opt, QStyle.SC_ComboBoxEditField, combo
|
||||
).width()
|
||||
|
||||
|
||||
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_vi(qapp, i18n_sach):
|
||||
"""Dựng ở tiếng Nhật, đổi sang tiếng Việt → nhãn/tooltip/4 chế độ phải là tiếng Việt."""
|
||||
i18n.set_language("ja")
|
||||
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||
|
||||
i18n.set_language("vi")
|
||||
|
||||
_chu_dinh_tuyen_phai_la(toggle, "vi")
|
||||
|
||||
|
||||
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_ja_roi_doi_sang_en(qapp, i18n_sach):
|
||||
"""Dựng ở tiếng Nhật, đổi sang tiếng Anh → toàn bộ chữ phải là tiếng Anh."""
|
||||
i18n.set_language("ja")
|
||||
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||
|
||||
i18n.set_language("en")
|
||||
|
||||
_chu_dinh_tuyen_phai_la(toggle, "en")
|
||||
|
||||
|
||||
def test_dinh_tuyen_dung_ngon_ngu_khi_dung_o_vi_roi_doi_sang_ja(qapp, i18n_sach):
|
||||
"""Chiều ngược lại: dựng ở tiếng Việt, đổi sang tiếng Nhật."""
|
||||
i18n.set_language("vi")
|
||||
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||
|
||||
i18n.set_language("ja")
|
||||
|
||||
_chu_dinh_tuyen_phai_la(toggle, "ja")
|
||||
|
||||
|
||||
def test_doi_ngon_ngu_khong_lam_doi_che_do_dinh_tuyen(qapp, i18n_sach):
|
||||
"""Test KHOÁ: dịch lại chỉ được đổi CHỮ, không đổi chế độ và không ghi đĩa.
|
||||
|
||||
Cột ``data`` ("off"/"auto"/"manual"/"fallback") được persist xuống đĩa; nếu
|
||||
ai đó dịch luôn cột đó, hoặc đổi ``retranslate()`` sang ``clear()+addItem()``,
|
||||
thì mode routing của mọi workspace bị âm thầm reset về "off".
|
||||
"""
|
||||
i18n.set_language("ja")
|
||||
ctx = FakeCtx()
|
||||
toggle = RoutingToggle(ctx, "cowork")
|
||||
|
||||
for lang in ("vi", "en", "ja"):
|
||||
i18n.set_language(lang)
|
||||
|
||||
assert toggle.current_mode() == "manual"
|
||||
assert toggle._combo.currentData() == "manual"
|
||||
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại chế độ định tuyến"
|
||||
|
||||
|
||||
def test_tu_chay_dung_ngon_ngu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
|
||||
"""Ô "Tự chạy" cũng phải đổi chữ, và không được tự đổi trạng thái tick."""
|
||||
i18n.set_language("ja")
|
||||
ctx = FakeCtx()
|
||||
toggle = AutoRunToggle(ctx)
|
||||
|
||||
i18n.set_language("vi")
|
||||
|
||||
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["vi"]
|
||||
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["vi"]
|
||||
|
||||
i18n.set_language("en")
|
||||
|
||||
assert toggle._chk.text() == i18n.STRINGS["routing.autorun_label"]["en"]
|
||||
assert toggle._chk.toolTip() == i18n.STRINGS["routing.autorun_tooltip"]["en"]
|
||||
assert toggle._chk.isChecked() is True
|
||||
assert ctx.writes == [], "đổi ngôn ngữ không được ghi lại cờ tự chạy"
|
||||
|
||||
|
||||
def test_bon_che_do_khong_bi_cat_chu_sau_khi_doi_ngon_ngu(qapp, i18n_sach):
|
||||
"""Dịch lại xong thì combo phải rộng ra theo chữ mới, không nuốt đuôi.
|
||||
|
||||
Combo dựng ở tiếng Nhật rồi đổi sang tiếng Việt: tên chế độ tiếng Việt dài
|
||||
hơn tên tiếng Nhật, nên nếu bề rộng còn bị khoá theo lần hiện đầu tiên thì
|
||||
"Thủ công"/"Dự phòng" (96px) không lọt ô chữ 89px và bị cắt đuôi.
|
||||
|
||||
Hai lớp assert, mỗi lớp bắt một nửa bản vá:
|
||||
|
||||
* chữ phải là tiếng Việt — bắt việc quên đăng ký ``on_language_changed``;
|
||||
* chữ phải lọt ô — bắt việc quên ``setSizeAdjustPolicy``, vì combo chỉ đo
|
||||
lại bề rộng khi chính sách là ``AdjustToContents``.
|
||||
|
||||
``host`` được nới rộng có chủ ý: ``addStretch`` phải còn chỗ trống để nhả ra
|
||||
cho combo, nếu không thì cả combo đúng lẫn combo sai đều kẹt ở bề rộng cũ và
|
||||
test không phân biệt được hai trạng thái.
|
||||
"""
|
||||
from PySide6.QtGui import QFontMetrics
|
||||
from PySide6.QtWidgets import QHBoxLayout, QWidget
|
||||
|
||||
i18n.set_language("ja")
|
||||
host = QWidget()
|
||||
lay = QHBoxLayout(host)
|
||||
toggle = RoutingToggle(FakeCtx(), "cowork")
|
||||
lay.addWidget(toggle)
|
||||
lay.addStretch(1)
|
||||
host.resize(600, 60)
|
||||
host.show()
|
||||
_on_dinh_layout(qapp)
|
||||
|
||||
i18n.set_language("vi")
|
||||
_on_dinh_layout(qapp)
|
||||
|
||||
combo = toggle._combo
|
||||
fm = QFontMetrics(combo.font())
|
||||
o_chu = _be_rong_o_chu(combo)
|
||||
chu_hien = [combo.itemText(i) for i in range(combo.count())]
|
||||
bi_cat = [t for t in chu_hien if fm.horizontalAdvance(t) > o_chu]
|
||||
host.close()
|
||||
|
||||
assert chu_hien == [i18n.STRINGS[key]["vi"] for _value, key in toggle._modes]
|
||||
assert not bi_cat, f"bị cắt chữ: {bi_cat} (ô chữ rộng {o_chu}px)"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", [
|
||||
"routing.toggle_label", "routing.toggle_tooltip",
|
||||
"routing.autorun_label", "routing.autorun_tooltip",
|
||||
"routing.mode_off", "routing.mode_auto",
|
||||
"routing.mode_manual", "routing.mode_fallback",
|
||||
])
|
||||
def test_key_routing_co_du_ba_ngon_ngu(key):
|
||||
"""Test KHOÁ: thiếu một bản dịch thì ``tr()`` rơi về tiếng Anh, lỗi lại tái diễn."""
|
||||
for lang in ("en", "ja", "vi"):
|
||||
assert i18n.STRINGS[key].get(lang), f"{key} thiếu {lang}"
|
||||
Reference in New Issue
Block a user