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:
2026-09-08 21:23:12 +09:00
co-authored by Claude Opus 5
parent 2f3cd100f8
commit fe99bc8727
31 changed files with 1625 additions and 131 deletions
+174
View File
@@ -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
+325
View File
@@ -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
+212
View File
@@ -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}"