fix(i18n): dịch nốt chữ do Qt tự vẽ, và thêm dòng phiên bản ở góc phải
CI / test (pull_request) Canceled after 0s
CI / test (pull_request) Canceled after 0s
Người dùng báo: chọn tiếng Nhật mà nhóm Sandbox Security, nút Save/Cancel và
nhiều chỗ khác vẫn tiếng Anh. Bộ test i18n cũ vẫn xanh vì nó chỉ bắt lỗi "có
dịch nhưng không ai áp lại" — hai lỗ thật nằm chỗ khác:
* Chuỗi HARDCODE không đi qua ``tr()`` bao giờ (``QPushButton("Unlock")``), nên
phép đo "đổi ngôn ngữ rồi tìm chỗ không mang mốc" thấy nó đứng yên ở cả hai
lần chụp và coi là bình thường.
* Nhãn nút do CHÍNH Qt vẽ. ``QDialogButtonBox``, ``QMessageBox.question`` và
``QInputDialog.get*`` lấy chữ từ bảng dịch riêng của Qt; ứng dụng không cài
``QTranslator`` nào và bản PySide6 đang dùng cũng không đóng gói file
``qtbase_*.qm`` nào để cài — nên chúng luôn rơi về tiếng Anh.
``ui/dialog_buttons.py`` gán nhãn của dự án đè lên nhãn Qt: ``dialog_buttons``
(10 hộp thoại), ``confirm`` (13 hộp Có/Không), ``ask_text``/``ask_multiline``/
``ask_item`` (16 hộp nhập liệu). Cùng với 17 chuỗi hardcode và 5 câu lỗi mà
``core/tasks.py`` trả thẳng ra hộp thoại — nay trả KHOÁ i18n, nơi hiển thị mới
gọi ``tr()`` — là 61 chỗ.
Ba chỗ nữa cùng lớp lỗi, phát hiện khi rà lại:
* ``_add_section`` nhận chuỗi ĐÃ dịch nên bốn tiêu đề mục của Step config đứng
nguyên ở ngôn ngữ lúc dựng panel. Nay nhận khoá + ``bind_dynamic`` để không
mất trạng thái gập/mở khi đổi ngôn ngữ.
* Thẻ tool ở Giám sát ▸ Công cụ hiện thẳng ``spec.description`` — chuỗi gửi cho
MÔ HÌNH trong schema function-calling, phải giữ tiếng Anh. Thêm bộ mô tả hiển
thị riêng cho 9 tool.
* Tên nhóm catalog ở tab Connector ("Other (any generic MCP server)").
Kèm theo, phần giao diện người dùng yêu cầu:
* ``__version__`` 2.26.0 -> 0.0.1, một nguồn cho tiêu đề cửa sổ, tab Giới thiệu
và dòng mới ở góc phải thanh trạng thái (thay dòng ghi công tác giả).
* Tắt size grip: nó vẽ một vệt ngay bên phải dòng phiên bản. Cửa sổ vẫn kéo
giãn được từ các cạnh.
* ``_NAV_SETTINGS_GAP`` 10 -> 4: hàng Cài đặt bớt xa nhóm Dashboard/Giám sát.
Hai test SẼ TREO nếu không sửa kèm: chúng patch ``QInputDialog.getText/getItem``
để tự trả lời, mà code nay gọi ``ask_text``/``ask_item`` — patch không còn chặn
được và hộp thoại thật sẽ mở ra chờ người bấm.
Ba cổng mới trong ``tests/ui/test_i18n_khong_hardcode_chu.py`` canh ở mức cấu
trúc (không ai được dựng lại kiểu cũ); đã kiểm chúng CẮN trên bản trước khi sửa:
10 + 13 + 17 vi phạm.
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
aeecdf5da0
commit
d2101e1f87
@@ -63,7 +63,7 @@ QUIRK DA DONG DINH (xem case tuong ung ben duoi):
|
||||
duoc goi, nhung ``step.sub_agents`` KHONG bi dung vao neu ``is_par`` False
|
||||
— du liệu van con trong step, chi khong hien tren UI.
|
||||
* ``_add_subagent``: khi ``_available_agent_names()`` tra ve danh sach RONG,
|
||||
code chuyen sang ``QInputDialog.getText`` thay vi ``getItem`` (dong
|
||||
code chuyen sang ``ask_text`` thay vi ``ask_item`` (dong
|
||||
401-402) — nhanh fallback nay chi cham toi khi khong con agent nao (builtin
|
||||
+ custom) de chon, hiem khi xay ra tren du lieu that nhung van la mot
|
||||
nhanh code song.
|
||||
@@ -98,7 +98,7 @@ import sys
|
||||
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
|
||||
|
||||
from pathlib import Path as _PP
|
||||
from PySide6.QtWidgets import QApplication, QInputDialog, QFileDialog, QListWidgetItem
|
||||
from PySide6.QtWidgets import QApplication, QFileDialog, QListWidgetItem
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from cowork_local.config import CONFIG_DIR
|
||||
@@ -269,25 +269,31 @@ assert p.sub_list.count() == before_n, "_step None -> _add_subagent la no-op"
|
||||
print("CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK")
|
||||
|
||||
p._step = pstep
|
||||
orig_getItem = QInputDialog.getItem
|
||||
orig_getText = QInputDialog.getText
|
||||
# Panel goi ask_item/ask_text cua ui/dialog_buttons (khong con goi thang ham
|
||||
# tinh QInputDialog.get*: ham tinh tu dung hop thoai ben trong nen nut "Cancel"
|
||||
# cua no luon tieng Anh). Patch dung namespace cua mixin dang goi — patch vao
|
||||
# QInputDialog nhu truoc se KHONG chan duoc gi va hop thoai that se mo ra treo.
|
||||
from cowork_local.presentation.co4e import node_property_actions_mixin as _acts
|
||||
|
||||
orig_getItem = _acts.ask_item
|
||||
orig_getText = _acts.ask_text
|
||||
changed_n = {"n": 0}
|
||||
p.changed.connect(lambda: changed_n.__setitem__("n", changed_n["n"] + 1))
|
||||
|
||||
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Picked Agent", True))
|
||||
_acts.ask_item = lambda *a, **k: ("Picked Agent", True)
|
||||
p._add_subagent()
|
||||
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"]
|
||||
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["Picked Agent"]
|
||||
assert changed_n["n"] == 1
|
||||
print("CASE_ADD_SUBAGENT_PICKED_OK")
|
||||
|
||||
QInputDialog.getItem = staticmethod(lambda *a, **k: ("", True))
|
||||
_acts.ask_item = lambda *a, **k: ("", True)
|
||||
p._add_subagent()
|
||||
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ten rong (sau strip) -> khong them"
|
||||
assert changed_n["n"] == 1, "khong them thi khong emit changed"
|
||||
print("CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK")
|
||||
|
||||
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Should Not Add", False))
|
||||
_acts.ask_item = lambda *a, **k: ("Should Not Add", False)
|
||||
p._add_subagent()
|
||||
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ok=False -> khong them"
|
||||
print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
|
||||
@@ -303,12 +309,12 @@ print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
|
||||
# StepConfigPanel de no roi ve lai dung method ke thua tu mixin - tuong duong
|
||||
# hanh vi voi cach "luu roi gan lai" cu, khong lam yeu di assert nao.
|
||||
StepConfigPanel._available_agent_names = staticmethod(lambda: [])
|
||||
QInputDialog.getText = staticmethod(lambda *a, **k: ("Typed Agent", True))
|
||||
_acts.ask_text = lambda *a, **k: ("Typed Agent", True)
|
||||
p._add_subagent()
|
||||
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent", "Typed Agent"]
|
||||
del StepConfigPanel._available_agent_names
|
||||
QInputDialog.getItem = orig_getItem
|
||||
QInputDialog.getText = orig_getText
|
||||
_acts.ask_item = orig_getItem
|
||||
_acts.ask_text = orig_getText
|
||||
print("CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK")
|
||||
|
||||
# ---- _edit_subagent (410-428) -----------------------------------------------
|
||||
@@ -323,7 +329,7 @@ def _fake_get_item(*a, **k):
|
||||
return ("Renamed", True)
|
||||
|
||||
|
||||
QInputDialog.getItem = staticmethod(_fake_get_item)
|
||||
_acts.ask_item = _fake_get_item
|
||||
item0 = p.sub_list.item(0)
|
||||
p._edit_subagent(item0)
|
||||
assert captured["current_index"] == 0, "quirk: cur khong nam trong names -> mo dialog tai index 0"
|
||||
@@ -335,7 +341,7 @@ foreign_item = QListWidgetItem("not in list")
|
||||
snapshot = dict(captured)
|
||||
p._edit_subagent(foreign_item)
|
||||
assert captured == snapshot, "item khong thuoc sub_list (row=-1) -> no-op"
|
||||
QInputDialog.getItem = orig_getItem
|
||||
_acts.ask_item = orig_getItem
|
||||
print("CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK")
|
||||
|
||||
# ---- _del_subagent (430-437) -------------------------------------------------
|
||||
|
||||
@@ -35,11 +35,14 @@ def test_construction_and_retranslate(qapp, tmp_path) -> None:
|
||||
|
||||
|
||||
def test_add_and_delete_pricing_row_round_trip(qapp, tmp_path, monkeypatch) -> None:
|
||||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
ctx = _FakeCtx(tmp_path)
|
||||
panel = PricingPanel(ctx, on_status_message=lambda _m: None)
|
||||
monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("gpt-test", True)))
|
||||
# Panel hỏi tên qua ``ui.dialog_buttons.ask_text`` chứ không gọi thẳng hàm
|
||||
# tĩnh ``QInputDialog.getText`` (hàm tĩnh tự dựng hộp thoại bên trong nên
|
||||
# nút Cancel của nó luôn tiếng Anh). Patch nhầm chỗ thì hộp thoại THẬT mở
|
||||
# ra và test treo cho tới khi hết giờ.
|
||||
monkeypatch.setattr("cowork_local.ui.dialog_buttons.ask_text",
|
||||
lambda *a, **k: ("gpt-test", True))
|
||||
panel._add_pricing_row()
|
||||
assert panel.table.rowCount() == 1
|
||||
assert panel.table.item(0, 0).text() == "gpt-test"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""Mục "Giới thiệu" trong Cài đặt, và việc gỡ dòng ghi công khỏi thanh trạng thái.
|
||||
"""Mục "Giới thiệu" trong Cài đặt, và dòng phiên bản ở góc dưới phải.
|
||||
|
||||
Dòng "Made by QuanDH14" từng là widget thường trực ở góc dưới phải: chiếm một góc
|
||||
màn hình trên MỌI màn hình, suốt cả phiên, cho một thông tin chỉ cần đọc một lần.
|
||||
Nó chuyển vào Cài đặt ▸ Giới thiệu — vẫn tra được, không còn đứng thường trực.
|
||||
Dòng "Made by QuanDH14" từng là widget thường trực ở góc dưới phải, rồi chuyển
|
||||
hẳn vào Cài đặt ▸ Giới thiệu. Nay chỗ đó mang MỘT thông tin khác: số phiên bản —
|
||||
người dùng muốn tra được ngay mà không phải mở Cài đặt.
|
||||
|
||||
Hai đầu của việc chuyển này phải được chốt cùng nhau: chỉ thêm mục mới mà quên gỡ
|
||||
chỗ cũ thì thành hiện hai lần; chỉ gỡ mà quên thêm thì mất hẳn.
|
||||
Điểm phải chốt là **một nguồn duy nhất**: tiêu đề cửa sổ, tab Giới thiệu và góc
|
||||
dưới phải đều đọc ``cowork_local.__version__``. Ghi cứng con số ở một trong ba
|
||||
chỗ thì lần nâng phiên bản sau sẽ để lại một chỗ nói sai.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -41,11 +42,13 @@ def test_muc_gioi_thieu_dung_cuoi(dialog):
|
||||
assert last == tr("settings.group.about")
|
||||
|
||||
|
||||
def test_muc_gioi_thieu_hien_dong_ghi_cong(dialog):
|
||||
def test_muc_gioi_thieu_hien_dong_phien_ban(dialog):
|
||||
from cowork_local import __version__
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
assert dialog._about_page.credit_label.text() == tr("app.credit")
|
||||
assert "QuanDH14" in dialog._about_page.credit_label.text()
|
||||
text = dialog._about_page.version_label.text()
|
||||
assert text == tr("app.version", v=__version__)
|
||||
assert __version__ in text
|
||||
|
||||
|
||||
def test_muc_gioi_thieu_hien_ten_va_phien_ban(dialog):
|
||||
@@ -56,10 +59,10 @@ def test_muc_gioi_thieu_hien_ten_va_phien_ban(dialog):
|
||||
assert __version__ in text
|
||||
|
||||
|
||||
# ---- đầu CŨ: không còn ở thanh trạng thái --------------------------------
|
||||
# ---- góc dưới phải: đúng một dòng, và là dòng phiên bản ------------------
|
||||
|
||||
def test_khong_con_dong_ghi_cong_trong_ma_nguon_cua_so_chinh():
|
||||
"""Chốt cả hai đầu: cửa sổ chính không được dựng lại widget ghi công.
|
||||
def test_goc_duoi_phai_hien_phien_ban_tu_cung_mot_nguon():
|
||||
"""Thanh trạng thái mang ĐÚNG một widget thường trực, là dòng phiên bản.
|
||||
|
||||
Kiểm trên mã nguồn thay vì dựng ``MainWindow`` — dựng cửa sổ thật mất ~35
|
||||
giây cho một khẳng định mà đọc file trả lời được ngay.
|
||||
@@ -69,7 +72,13 @@ def test_khong_con_dong_ghi_cong_trong_ma_nguon_cua_so_chinh():
|
||||
src = (Path(__file__).resolve().parents[2]
|
||||
/ "presentation" / "shell" / "main_window.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "_credit" not in src, "vẫn còn widget ghi công ở cửa sổ chính"
|
||||
assert "addPermanentWidget" not in src, (
|
||||
"thanh trạng thái lại có widget thường trực — nếu là thứ khác thì sửa "
|
||||
"khẳng định này, nhưng phải cân nhắc: nó chiếm một góc trên mọi màn hình")
|
||||
assert "_credit" not in src, "widget ghi công tác giả đã chuyển vào Cài đặt"
|
||||
assert src.count("addPermanentWidget") == 1, (
|
||||
"góc dưới phải chỉ được mang MỘT widget thường trực — nó chiếm một góc "
|
||||
"màn hình trên MỌI màn hình, suốt cả phiên làm việc")
|
||||
assert 'tr("app.version", v=__version__)' in src, (
|
||||
"dòng phiên bản phải đọc __version__ như tiêu đề cửa sổ và tab Giới "
|
||||
"thiệu; ghi cứng con số ở đây thì lần nâng phiên bản sau sẽ nói sai")
|
||||
assert "setSizeGripEnabled(False)" in src, (
|
||||
"size grip vẽ một vệt ngay bên phải dòng phiên bản — góc đó chỉ được "
|
||||
"mang mỗi con số; cửa sổ vẫn kéo giãn được từ các cạnh")
|
||||
|
||||
@@ -34,7 +34,7 @@ MOC = "\u2063" # invisible separator: không chuỗi hiển thị thật nào
|
||||
#: 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.logo", "app.version", "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",
|
||||
@@ -44,11 +44,15 @@ KHOA_KHONG_CAN_DICH = {
|
||||
"settings.group.teams", "settings.history_onedrive",
|
||||
"settings.ms365_connector.onedrive", "settings.ms365_connector.outlook",
|
||||
"settings.ms365_connector.sharepoint", "settings.ms365_connector.teams",
|
||||
# tên nhóm catalog = danh sách tên sản phẩm, giống hệt ở cả ba ngôn ngữ
|
||||
"connectors.cat_cad", "connectors.cat_cae", "connectors.cat_ms365",
|
||||
"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
|
||||
# "OK" giữ nguyên dạng ở cả ba ngôn ngữ — bản tiếng Nhật của chính Qt cũng dùng "OK"
|
||||
"dialog.ok",
|
||||
# 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",
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
"""Chữ trên giao diện phải đi qua ``tr()`` — kể cả chữ do CHÍNH Qt vẽ.
|
||||
|
||||
Bộ ``test_i18n_khong_con_chu_cu.py`` bắt lỗi "có dịch nhưng không ai áp lại".
|
||||
Nó không bắt được lỗi người dùng báo tháng 9/2026: "đang tiếng Nhật mà nút Save,
|
||||
Cancel và phần mật khẩu sandbox vẫn tiếng Anh". Lý do là hai lỗ khác hẳn:
|
||||
|
||||
* **Chuỗi hardcode.** ``QPushButton("Unlock")`` không hỏi ``tr()`` bao giờ, nên
|
||||
phép đo "đổi ngôn ngữ rồi tìm chỗ không mang mốc" thấy nó đứng yên ở cả hai
|
||||
lần chụp và coi là bình thường.
|
||||
* **Nhãn nút của chính Qt.** ``QDialogButtonBox(Save | Cancel)`` và
|
||||
``QMessageBox.question`` lấy chữ từ bảng dịch của Qt. Ứng dụng không cài
|
||||
``QTranslator`` nào nên bảng đó luôn rơi về tiếng Anh — không dòng mã nào của
|
||||
dự án sai, mà màn hình vẫn sai.
|
||||
|
||||
Ba cổng dưới đây canh cả hai lỗ, và canh ở mức CẤU TRÚC (không ai được dựng lại
|
||||
kiểu cũ) chứ không chỉ sửa xong từng chỗ.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = pathlib.Path(__file__).resolve().parents[2]
|
||||
#: Chỉ hai tầng này vẽ giao diện.
|
||||
THU_MUC_UI = ("ui", "presentation")
|
||||
#: Nơi DUY NHẤT được phép chạm vào nút chuẩn của Qt — chính chỗ gán nhãn lại.
|
||||
NOI_BOC_QT = "ui/dialog_buttons.py"
|
||||
|
||||
|
||||
def _file_ui():
|
||||
"""Mọi file .py thuộc tầng giao diện, kèm cây AST đã phân tích."""
|
||||
for thu_muc in THU_MUC_UI:
|
||||
for path in sorted((REPO / thu_muc).rglob("*.py")):
|
||||
rel = path.relative_to(REPO).as_posix()
|
||||
yield rel, ast.parse(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _ten_ham(func) -> str:
|
||||
if isinstance(func, ast.Name):
|
||||
return func.id
|
||||
if isinstance(func, ast.Attribute):
|
||||
return func.attr
|
||||
return ""
|
||||
|
||||
|
||||
# ---- cấu trúc: không ai được dùng lại nhãn mặc định của Qt ----------------
|
||||
|
||||
def test_khong_dung_nut_chuan_qdialogbuttonbox_truc_tiep():
|
||||
"""``QDialogButtonBox(Save | Cancel)`` phải đi qua ``dialog_buttons()``.
|
||||
|
||||
Dựng thẳng thì Qt tự vẽ chữ, và chữ đó là tiếng Anh ở cả ba ngôn ngữ.
|
||||
"""
|
||||
vi_pham = []
|
||||
for rel, tree in _file_ui():
|
||||
if rel == NOI_BOC_QT:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and _ten_ham(node.func) == "QDialogButtonBox"
|
||||
and node.args):
|
||||
# Không tham số = hộp rỗng rồi tự addButton(tr(...)) — vẫn đúng.
|
||||
vi_pham.append(f"{rel}:{node.lineno}")
|
||||
|
||||
assert vi_pham == [], (
|
||||
"dựng QDialogButtonBox với nút chuẩn thì nhãn do Qt vẽ và luôn là tiếng "
|
||||
"Anh — dùng ui.dialog_buttons.dialog_buttons() thay vào:\n "
|
||||
+ "\n ".join(vi_pham))
|
||||
|
||||
|
||||
def test_khong_goi_qinputdialog_tinh_truc_tiep():
|
||||
"""Hộp nhập liệu phải đi qua ``ask_text`` / ``ask_multiline`` / ``ask_item``.
|
||||
|
||||
``QInputDialog.getText`` và họ hàng tự dựng hộp thoại bên trong nên không ai
|
||||
chạm được vào nút của nó: "Cancel" đứng nguyên tiếng Anh ở cả ja lẫn vi.
|
||||
"""
|
||||
vi_pham = []
|
||||
for rel, tree in _file_ui():
|
||||
if rel == NOI_BOC_QT:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "QInputDialog"
|
||||
and node.func.attr.startswith("get")):
|
||||
vi_pham.append(f"{rel}:{node.lineno} QInputDialog.{node.func.attr}")
|
||||
|
||||
assert vi_pham == [], (
|
||||
"hàm tĩnh QInputDialog hiện nút Cancel tiếng Anh ở mọi ngôn ngữ — dùng "
|
||||
"ask_text / ask_multiline / ask_item của ui.dialog_buttons:\n "
|
||||
+ "\n ".join(vi_pham))
|
||||
|
||||
|
||||
def test_khong_goi_qmessagebox_question_truc_tiep():
|
||||
"""Hộp hỏi Có/Không phải đi qua ``confirm()``.
|
||||
|
||||
``QMessageBox.question`` hiện "&Yes" / "&No" tiếng Anh ở mọi ngôn ngữ.
|
||||
"""
|
||||
vi_pham = []
|
||||
for rel, tree in _file_ui():
|
||||
if rel == NOI_BOC_QT:
|
||||
continue
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "question"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "QMessageBox"):
|
||||
vi_pham.append(f"{rel}:{node.lineno}")
|
||||
|
||||
assert vi_pham == [], (
|
||||
"QMessageBox.question hiện nút Yes/No tiếng Anh ở mọi ngôn ngữ — dùng "
|
||||
"ui.dialog_buttons.confirm() thay vào:\n " + "\n ".join(vi_pham))
|
||||
|
||||
|
||||
# ---- dữ liệu: không chuỗi hiển thị nào được viết thẳng vào widget ----------
|
||||
|
||||
#: Đối số thứ N của các hàm dựng widget này là NHÃN hiển thị.
|
||||
CTOR_NHAN = {
|
||||
"QLabel": 0, "QPushButton": 0, "QGroupBox": 0, "QCheckBox": 0,
|
||||
"QRadioButton": 0, "QToolButton": 0, "QAction": 0, "QMenu": 0,
|
||||
"QListWidgetItem": 0, "QTreeWidgetItem": 0, "QTableWidgetItem": 0,
|
||||
"IconLabel": 1, "ToggleSwitch": 0,
|
||||
}
|
||||
#: Đối số thứ N của các setter này là chuỗi hiển thị.
|
||||
SETTER_NHAN = {
|
||||
"setText": 0, "setToolTip": 0, "setPlaceholderText": 0, "setWindowTitle": 0,
|
||||
"setTitle": 0, "setStatusTip": 0, "setWhatsThis": 0, "setLabelText": 0,
|
||||
"setInformativeText": 0, "setAccessibleName": 0, "addItem": 0,
|
||||
"insertItem": 1, "setItemText": 1, "addAction": 0, "addTab": 1,
|
||||
"insertTab": 2, "setTabText": 1, "setTabToolTip": 1, "setHeaderLabel": 0,
|
||||
}
|
||||
#: Mọi đối số chuỗi đều là nhãn.
|
||||
SETTER_NHAN_TAT_CA = {
|
||||
"setHorizontalHeaderLabels", "setHeaderLabels", "setVerticalHeaderLabels",
|
||||
}
|
||||
#: ``QMessageBox.<loai>(parent, title, body)`` — đối số 1 và 2 là chữ hiển thị.
|
||||
MSGBOX_LOAI = {"information", "warning", "critical", "about"}
|
||||
|
||||
#: Chuỗi cố ý viết thẳng, kèm lý do. Thêm mục ở đây là một quyết định, không
|
||||
#: phải cách làm cổng xanh lại — chữ người dùng ĐỌC ĐƯỢC thì phải dịch.
|
||||
CHO_PHEP_HARDCODE = {
|
||||
# ký hiệu đơn vị và tiền tố phiên bản: giống nhau ở cả ba ngôn ngữ
|
||||
("presentation/monitoring/tabs/overview_tab.py", "/s"),
|
||||
("presentation/settings/about_widget.py", " v"),
|
||||
("presentation/settings/routing_settings_widget.py", " s"),
|
||||
("presentation/settings/routing_settings_widget.py", " h"),
|
||||
("presentation/shell/main_window.py", " v"),
|
||||
("ui/task_editor_dialog.py", " s"),
|
||||
}
|
||||
|
||||
|
||||
def _chuoi(node) -> str | None:
|
||||
"""Chữ của một literal chuỗi. Bỏ qua f-string đã có ``tr()`` bên trong."""
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
if any(isinstance(n, ast.Call) and _ten_ham(n.func) == "tr"
|
||||
for n in ast.walk(node)):
|
||||
return None
|
||||
phan = [v.value for v in node.values
|
||||
if isinstance(v, ast.Constant) and isinstance(v.value, str)]
|
||||
return "".join(phan) if phan else None
|
||||
return None
|
||||
|
||||
|
||||
def _la_chu_nguoi_doc(s: str) -> bool:
|
||||
"""Chuỗi có phải chữ người dùng ĐỌC, chứ không phải mã/ký hiệu/định dạng.
|
||||
|
||||
Mốc "ít nhất ba chữ cái" loại được đơn vị (``/s``, `` h``) và tiền tố phiên
|
||||
bản (`` v``) mà vẫn giữ lại mọi nhãn thật — nhãn ngắn nhất trên màn này là
|
||||
"Unlock".
|
||||
"""
|
||||
import re
|
||||
|
||||
if len(s.strip()) < 2 or re.fullmatch(r"[\W\d_]+", s):
|
||||
return False
|
||||
if re.fullmatch(r"[a-z][a-zA-Z0-9]*|[a-z0-9_.]+", s):
|
||||
return False # objectName / khoá dạng định danh
|
||||
if s.startswith(("http://", "https://", "#", "<")):
|
||||
return False
|
||||
return len(re.findall(r"[A-Za-zÀ-ỹ-ヿ一-鿿]", s)) >= 3
|
||||
|
||||
|
||||
def test_khong_con_chuoi_hien_thi_viet_thang_vao_widget():
|
||||
"""Không nhãn/tooltip/placeholder nào được viết thẳng bằng một ngôn ngữ.
|
||||
|
||||
Đây là nửa còn lại của lỗi tháng 9/2026: nhóm mật khẩu Sandbox Security
|
||||
dựng bằng ``QPushButton("Unlock")`` và ``IconLabel(..., "Locked ...")``, nên
|
||||
đổi sang tiếng Nhật thì cả cụm vẫn nguyên tiếng Anh.
|
||||
"""
|
||||
vi_pham = []
|
||||
for rel, tree in _file_ui():
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn, args = _ten_ham(node.func), node.args
|
||||
|
||||
def bao(i: int) -> None:
|
||||
if i >= len(args):
|
||||
return
|
||||
s = _chuoi(args[i])
|
||||
if s and _la_chu_nguoi_doc(s) and (rel, s) not in CHO_PHEP_HARDCODE:
|
||||
vi_pham.append(f"{rel}:{args[i].lineno} {fn}({s!r})")
|
||||
|
||||
if fn in CTOR_NHAN:
|
||||
bao(CTOR_NHAN[fn])
|
||||
if fn in SETTER_NHAN:
|
||||
bao(SETTER_NHAN[fn])
|
||||
if fn in SETTER_NHAN_TAT_CA:
|
||||
for i in range(len(args)):
|
||||
bao(i)
|
||||
if (fn in MSGBOX_LOAI and isinstance(node.func, ast.Attribute)
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "QMessageBox"):
|
||||
bao(1)
|
||||
bao(2)
|
||||
|
||||
assert vi_pham == [], (
|
||||
"chữ hiển thị viết thẳng, không đi qua tr() nên không đổi theo ngôn "
|
||||
"ngữ:\n " + "\n ".join(sorted(vi_pham)))
|
||||
|
||||
|
||||
def test_moi_tool_co_mo_ta_hien_thi_rieng():
|
||||
"""Mỗi tool trong ``TOOL_SPECS`` phải có khoá mô tả để hiển thị.
|
||||
|
||||
Thẻ tool ở Giám sát ▸ Công cụ từng hiện thẳng ``spec.description`` — chuỗi
|
||||
gửi cho MÔ HÌNH trong schema function-calling, nên luôn tiếng Anh. Thiếu
|
||||
khoá thì ``tr()`` trả về chính khoá, và người dùng đọc được
|
||||
``tools_admin.desc.<tên>`` ngay trên thẻ.
|
||||
"""
|
||||
from cowork_local.core.tools import TOOL_SPECS
|
||||
from cowork_local.i18n import STRINGS
|
||||
|
||||
thieu = [s.name for s in TOOL_SPECS
|
||||
if f"tools_admin.desc.{s.name}" not in STRINGS]
|
||||
|
||||
assert thieu == [], (
|
||||
"tool chưa có khoá mô tả hiển thị (thêm tools_admin.desc.<tên> vào "
|
||||
f"i18n/libreoffice_view.py): {thieu}")
|
||||
|
||||
|
||||
def test_add_section_nhan_khoa_chu_khong_nhan_chuoi_da_dich():
|
||||
"""``_add_section`` tự dựng nhãn mục, nên phải nhận KHOÁ.
|
||||
|
||||
Truyền ``tr(...)`` vào đây hỏng ÂM THẦM: khoá thiếu thì ``tr()`` trả về
|
||||
chính chuỗi đưa vào, nên tiêu đề hiện đúng ở ngôn ngữ lúc dựng panel rồi
|
||||
đứng nguyên mãi. Đúng lỗi người dùng báo: mở Co4E ▸ Step config ở tiếng
|
||||
Nhật thì đổi sang tiếng Anh vẫn còn tiếng Nhật.
|
||||
"""
|
||||
vi_pham = []
|
||||
for rel, tree in _file_ui():
|
||||
for node in ast.walk(tree):
|
||||
if (isinstance(node, ast.Call) and _ten_ham(node.func) == "_add_section"
|
||||
and len(node.args) >= 2
|
||||
and isinstance(node.args[1], ast.Call)
|
||||
and _ten_ham(node.args[1].func) == "tr"):
|
||||
vi_pham.append(f"{rel}:{node.lineno}")
|
||||
|
||||
assert vi_pham == [], (
|
||||
"_add_section nhận khoá dịch, không nhận tr(...) — truyền chuỗi đã dịch "
|
||||
"thì tiêu đề mục không bao giờ đổi lại:\n " + "\n ".join(vi_pham))
|
||||
|
||||
|
||||
# ---- lúc chạy: nhãn nút thật sự đổi theo ngôn ngữ -------------------------
|
||||
|
||||
def test_tieu_de_muc_step_config_doi_theo_ngon_ngu(qapp):
|
||||
"""Bốn tiêu đề mục của Step config phải đổi theo ngôn ngữ.
|
||||
|
||||
Panel này dựng LƯỜI (chỉ khi người dùng chọn một bước trên canvas Co4E) nên
|
||||
nó không nằm trong cây widget mà ``test_i18n_khong_con_chu_cu`` quét — phải
|
||||
dựng thẳng ở đây thì mới canh được.
|
||||
"""
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from cowork_local import i18n
|
||||
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
|
||||
from cowork_local.presentation.co4e.step_config_section import _SectionHeader
|
||||
|
||||
cu = i18n.get_language()
|
||||
try:
|
||||
i18n.set_language("ja")
|
||||
panel = StepConfigPanel() # dựng TRONG lúc đang tiếng Nhật
|
||||
nhat = [h.text() for h in panel.findChildren(_SectionHeader)]
|
||||
assert nhat, "không tìm thấy tiêu đề mục nào"
|
||||
|
||||
i18n.set_language("en")
|
||||
anh = [h.text() for h in panel.findChildren(_SectionHeader)]
|
||||
|
||||
assert all(a != n for a, n in zip(anh, nhat)), (
|
||||
f"tiêu đề mục giữ nguyên ngôn ngữ lúc dựng panel: {anh}")
|
||||
assert anh == [f"▶ {i18n.tr(k)}" for k in
|
||||
("co4e.tab_basic", "co4e.tab_model_perm",
|
||||
"co4e.tab_skills_files", "co4e.f_subagents")]
|
||||
finally:
|
||||
i18n.set_language(cu)
|
||||
|
||||
|
||||
def test_doi_ngon_ngu_khong_lam_mat_trang_thai_gap_mo(qapp):
|
||||
"""Mục đang MỞ vẫn phải mang dấu ▼ sau khi đổi ngôn ngữ.
|
||||
|
||||
Đây là lý do nhãn này dùng ``bind_dynamic`` chứ không ``bind_text``: bind
|
||||
cứng một khoá sẽ ghi đè cả dấu gập/mở, trả mục đang mở về ▶ trong khi phần
|
||||
thân của nó vẫn đang hiện.
|
||||
"""
|
||||
pytest.importorskip("PySide6")
|
||||
|
||||
from cowork_local import i18n
|
||||
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
|
||||
from cowork_local.presentation.co4e.step_config_section import _SectionHeader
|
||||
|
||||
cu = i18n.get_language()
|
||||
try:
|
||||
i18n.set_language("en")
|
||||
panel = StepConfigPanel()
|
||||
header = panel.findChildren(_SectionHeader)[0]
|
||||
header.clicked.emit() # mở mục đầu tiên
|
||||
assert header.text().startswith("▼")
|
||||
|
||||
i18n.set_language("ja")
|
||||
|
||||
assert header.text().startswith("▼"), (
|
||||
f"đổi ngôn ngữ làm mất trạng thái đang mở: {header.text()!r}")
|
||||
finally:
|
||||
i18n.set_language(cu)
|
||||
|
||||
def test_nhan_nut_hop_thoai_doi_theo_ngon_ngu(qapp):
|
||||
"""Nút Save/Cancel mang chữ của dự án ở cả ba ngôn ngữ.
|
||||
|
||||
Chốt luôn cái bẫy vòng đời: ràng buộc giữ widget bằng ``weakref``, mà nút
|
||||
chuẩn do C++ dựng nên wrapper Python của nó có thể bị thu hồi. Hộp thoại ở
|
||||
đây thả hết tham chiếu cục bộ, đúng như mọi call site thật.
|
||||
"""
|
||||
pytest.importorskip("PySide6")
|
||||
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
|
||||
|
||||
from cowork_local import i18n
|
||||
from cowork_local.ui.dialog_buttons import dialog_buttons
|
||||
|
||||
dlg = QDialog()
|
||||
QVBoxLayout(dlg).addWidget(
|
||||
dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel))
|
||||
hop = dlg.findChild(QDialogButtonBox)
|
||||
|
||||
cu = i18n.get_language()
|
||||
try:
|
||||
for lang in ("en", "ja", "vi"):
|
||||
i18n.set_language(lang)
|
||||
mong_doi = [i18n.STRINGS["dialog.save"][lang],
|
||||
i18n.STRINGS["dialog.cancel"][lang]]
|
||||
assert [b.text() for b in hop.buttons()] == mong_doi, (
|
||||
f"nhãn nút không theo ngôn ngữ {lang}")
|
||||
finally:
|
||||
i18n.set_language(cu)
|
||||
dlg.close()
|
||||
Reference in New Issue
Block a user